├── .envrc ├── codecov.yml ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── run-tests.yml ├── internal ├── comment │ ├── comment.go │ └── gitlab │ │ ├── poster_test.go │ │ └── poster.go ├── models │ ├── repo.go │ ├── application.go │ └── application_test.go ├── app │ ├── colors.go │ ├── diff_strategy_test.go │ ├── config_test.go │ ├── app_test.go │ ├── diff_strategy.go │ ├── target_test.go │ ├── target.go │ ├── config.go │ ├── git_test.go │ ├── compare.go │ ├── comment_strategy_test.go │ ├── app_integration_test.go │ ├── compare_test.go │ ├── git.go │ ├── app.go │ └── comment_strategy.go ├── ports │ └── ports.go ├── helpers │ ├── helpers.go │ └── helpers_test.go └── sanitizer │ ├── secret_masker_test.go │ └── secret_masker.go ├── .gitignore ├── cmd └── argo-compare │ ├── utils │ ├── globber.go │ ├── cmdrunner_test.go │ ├── file-reader.go │ ├── osfs.go │ ├── cmdrunner.go │ ├── globber_test.go │ ├── file-reader_test.go │ ├── osfs_test.go │ ├── helm-chart-processor.go │ └── helm-chart-processor_test.go │ ├── main.go │ └── command │ ├── root_test.go │ └── root.go ├── .pre-commit-config.yaml ├── patch └── diff-so-fancy.patch ├── flake.nix ├── LICENSE ├── Makefile ├── Dockerfile ├── flake.lock ├── go.mod ├── .goreleaser.yaml ├── README.md └── go.sum /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "**/*_test.go" 3 | - "cmd/argo-compare/mocks/**" 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /internal/comment/comment.go: -------------------------------------------------------------------------------- 1 | package comment 2 | 3 | // Poster can publish a formatted diff comment to an upstream system. 4 | type Poster interface { 5 | Post(body string) error 6 | } 7 | -------------------------------------------------------------------------------- /internal/models/repo.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // RepoCredentials stores authentication details for Helm repositories. 4 | type RepoCredentials struct { 5 | Url string `json:"url"` 6 | Username string `json:"username"` 7 | Password string `json:"password"` 8 | } 9 | -------------------------------------------------------------------------------- /internal/app/colors.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/fatih/color" 4 | 5 | var ( 6 | cyan = color.New(color.FgCyan, color.Bold).SprintFunc() 7 | red = color.New(color.FgRed, color.Bold).SprintFunc() 8 | yellow = color.New(color.FgYellow, color.Bold).SprintFunc() 9 | ) 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE files 2 | .idea 3 | 4 | # build artifacts 5 | bin/ 6 | dist/ 7 | 8 | # local env files 9 | .env 10 | 11 | # coverage files 12 | coverage.out 13 | coverage.html 14 | report.xml 15 | 16 | # generated mocks 17 | cmd/argo-compare/mocks/ 18 | 19 | # files generated by tests 20 | .tmp/ 21 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/globber.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/mattn/go-zglob" 4 | 5 | // CustomGlobber resolves glob patterns using mattn/go-zglob. 6 | type CustomGlobber struct{} 7 | 8 | // Glob expands pattern and returns the matching file paths. 9 | func (g CustomGlobber) Glob(pattern string) ([]string, error) { 10 | return zglob.Glob(pattern) 11 | } 12 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/cmdrunner_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRealCmdRunner_Run(t *testing.T) { 10 | runner := &RealCmdRunner{} 11 | cmd := "echo" 12 | args := []string{"hello"} 13 | 14 | stdout, stderr, err := runner.Run(cmd, args...) 15 | 16 | assert.NoError(t, err) 17 | assert.Equal(t, "hello\n", stdout) 18 | assert.Equal(t, "", stderr) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/file-reader.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | ) 7 | 8 | // OsFileReader reads files from the local filesystem. 9 | type OsFileReader struct{} 10 | 11 | // ReadFile returns the content of file or nil when the file does not exist. 12 | func (r OsFileReader) ReadFile(file string) []byte { 13 | if readFile, err := os.ReadFile(file); errors.Is(err, os.ErrNotExist) /* #nosec G304 */ { 14 | return nil 15 | } else { 16 | return readFile 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/osfs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "os" 4 | 5 | // RealOsFs wraps basic temporary file helpers from the os package. 6 | type RealOsFs struct{} 7 | 8 | // CreateTemp mirrors os.CreateTemp to allow abstraction in tests. 9 | func (r *RealOsFs) CreateTemp(dir, pattern string) (f *os.File, err error) { 10 | return os.CreateTemp(dir, pattern) 11 | } 12 | 13 | // Remove deletes the named file or directory. 14 | func (r *RealOsFs) Remove(name string) error { 15 | return os.Remove(name) 16 | } 17 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/cmdrunner.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "os/exec" 6 | ) 7 | 8 | // RealCmdRunner executes shell commands using the operating system. 9 | type RealCmdRunner struct{} 10 | 11 | // Run executes cmd with args and captures stdout and stderr strings. 12 | func (r *RealCmdRunner) Run(cmd string, args ...string) (string, string, error) { 13 | command := exec.Command(cmd, args...) 14 | 15 | var stdoutBuffer, stderrBuffer bytes.Buffer 16 | command.Stdout = &stdoutBuffer 17 | command.Stderr = &stderrBuffer 18 | 19 | err := command.Run() 20 | 21 | return stdoutBuffer.String(), stderrBuffer.String(), err 22 | } 23 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/globber_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCustomGlobber_Glob(t *testing.T) { 10 | // Create a pattern that matches some files in the filesystem 11 | pattern := "*.go" // Matches all Go source files in the current directory 12 | 13 | // Use the CustomGlobber to find matching files 14 | globber := CustomGlobber{} 15 | matches, err := globber.Glob(pattern) 16 | 17 | // Expect no error 18 | assert.NoError(t, err) 19 | 20 | // Expect at least one match (since there should be at least one Go source file) 21 | assert.True(t, len(matches) > 0) 22 | } 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | - id: no-commit-to-branch 10 | args: 11 | - --branch=main 12 | - repo: https://github.com/hadolint/hadolint 13 | rev: v2.12.0 14 | hooks: 15 | - id: hadolint 16 | args: 17 | - --ignore=DL3018 18 | - repo: https://github.com/dnephin/pre-commit-golang 19 | rev: v0.5.1 20 | hooks: 21 | - id: go-fmt 22 | - id: go-mod-tidy 23 | - id: go-imports 24 | -------------------------------------------------------------------------------- /patch/diff-so-fancy.patch: -------------------------------------------------------------------------------- 1 | --- /usr/local/bin/diff-so-fancy 2021-07-27 23:23:49 2 | +++ diff-so-fancy-patched.pl 2023-01-19 01:21:19 3 | @@ -663,13 +663,18 @@ 4 | } elsif ($file_2 eq "/dev/null") { 5 | my $del_color = $DiffHighlight::OLD_HIGHLIGHT[1]; 6 | return "deleted: $del_color$file_1$reset_color"; 7 | - # If the files aren't the same it's a rename 8 | + # If the files aren't the same it's a rename, but in case of argo-compare it is not 9 | + # hence we are making changes here 10 | } elsif ($file_1 ne $file_2) { 11 | my ($old, $new) = DiffHighlight::highlight_pair($file_1,$file_2,{only_diff => 1}); 12 | # highlight_pair already includes reset_color, but adds newline characters that need to be trimmed off 13 | $old = trim($old); 14 | $new = trim($new); 15 | - return "renamed: $old$meta_color to $new" 16 | + 17 | + my @fullFilePath = split("/", $new); 18 | + my $filePath = join("/", @fullFilePath[9..$#fullFilePath]); 19 | + 20 | + return "Found changes in: $filePath" 21 | # Something we haven't thought of yet 22 | } else { 23 | return "$file_1 -> $file_2"; 24 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Development environment for argo-compare with Go tooling and pre-commit hooks"; 3 | inputs = { 4 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | }; 7 | outputs = { self, nixpkgs, flake-utils }: 8 | flake-utils.lib.eachDefaultSystem (system: 9 | let 10 | pkgs = import nixpkgs { 11 | inherit system; 12 | }; 13 | 14 | goToolchain = with pkgs; [ 15 | go_1_24 16 | gopls 17 | gotools 18 | mockgen 19 | ]; 20 | 21 | preCommitTools = with pkgs; [ 22 | pre-commit 23 | hadolint 24 | git 25 | ]; 26 | in 27 | { 28 | devShells.default = pkgs.mkShell { 29 | packages = goToolchain ++ preCommitTools; 30 | shellHook = '' 31 | export GOPATH="$PWD/.go" 32 | export GOMODCACHE="$PWD/.gomod" 33 | mkdir -p "$GOPATH" "$GOMODCACHE" 34 | export GO111MODULE=on 35 | ''; 36 | }; 37 | } 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 Vadim Gedz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/file-reader_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestOsFileReader_ReadFile(t *testing.T) { 11 | // Temporary file content 12 | content := "Hello, World!" 13 | 14 | // Create a temporary file and write some content to it 15 | tempFile, err := os.CreateTemp("", "testfile") 16 | if err != nil { 17 | t.Fatal("Failed to create temp file.") 18 | } 19 | defer func(name string) { 20 | err := os.Remove(name) 21 | if err != nil { 22 | t.Fatal("Failed to remove temp file.") 23 | } 24 | }(tempFile.Name()) // Clean up after test 25 | 26 | _, err = tempFile.Write([]byte(content)) 27 | if err != nil { 28 | t.Fatal("Failed to write to temp file.") 29 | } 30 | 31 | if err := tempFile.Close(); err != nil { 32 | t.Fatal("Failed to close temp file.") 33 | } 34 | 35 | // Test reading an existing file 36 | reader := OsFileReader{} 37 | readContent := reader.ReadFile(tempFile.Name()) 38 | assert.Equal(t, content, string(readContent)) 39 | 40 | // Test reading a non-existing file 41 | nonExistingFileName := "non_existing_file.txt" 42 | readContent = reader.ReadFile(nonExistingFileName) 43 | assert.Nil(t, readContent) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/osfs_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRealOsFs_CreateTemp(t *testing.T) { 11 | r := RealOsFs{} 12 | dir := "" // Use the default temporary directory 13 | pattern := "testfile*" 14 | 15 | // Create a temporary file 16 | file, err := r.CreateTemp(dir, pattern) 17 | assert.NoError(t, err) 18 | defer func(file *os.File) { 19 | err := file.Close() 20 | if err != nil { 21 | t.Fatal("Failed to close temp file.") 22 | } 23 | }(file) 24 | 25 | // Check that the file exists 26 | _, err = os.Stat(file.Name()) 27 | assert.NoError(t, err) 28 | 29 | // Clean up 30 | err = r.Remove(file.Name()) 31 | assert.NoError(t, err) 32 | } 33 | 34 | func TestRealOsFs_Remove(t *testing.T) { 35 | r := RealOsFs{} 36 | dir := "" // Use the default temporary directory 37 | pattern := "testfile*" 38 | 39 | // Create a temporary file 40 | file, err := r.CreateTemp(dir, pattern) 41 | assert.NoError(t, err) 42 | defer func(file *os.File) { 43 | err := file.Close() 44 | if err != nil { 45 | t.Fatal("Failed to close temp file.") 46 | } 47 | }(file) 48 | 49 | // Remove the file 50 | err = r.Remove(file.Name()) 51 | assert.NoError(t, err) 52 | 53 | // Check that the file no longer exists 54 | _, err = os.Stat(file.Name()) 55 | assert.True(t, os.IsNotExist(err)) 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write # needed to write releases 10 | packages: write # needed to write packages 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Set up QEMU # required for multi architecture build - https://goreleaser.com/cookbooks/multi-platform-docker-images/?h=multi#other-things-to-pay-attention-to 17 | uses: docker/setup-qemu-action@v2 18 | 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 # this is important, otherwise it won't checkout the full tree (i.e. no previous tags) 22 | 23 | - name: Load secrets from 1Password 24 | uses: 1password/load-secrets-action@v1 25 | with: 26 | export-env: true 27 | env: 28 | OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} 29 | GITHUB_TOKEN: op://github-actions/github/token 30 | 31 | - name: Login to GHCR 32 | uses: docker/login-action@v2 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.actor }} 36 | password: ${{ env.GITHUB_TOKEN }} 37 | 38 | - uses: actions/setup-go@v4 39 | with: 40 | go-version-file: go.mod 41 | 42 | - uses: goreleaser/goreleaser-action@v5 43 | with: 44 | version: v1.25.1 45 | args: release --rm-dist 46 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help 4 | help: ## Print this help 5 | @echo "Usage: make [target]" 6 | @grep -E '^[a-z.A-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 7 | 8 | .PHONY: install-deps 9 | install-deps: ## Install dependencies 10 | @echo "===> Installing dependencies" 11 | @go install go.uber.org/mock/mockgen@latest 12 | @go install github.com/jstemmer/go-junit-report@latest 13 | 14 | .PHONY: mocks 15 | mocks: ## Generate mocks 16 | @echo "===> Generating mocks" 17 | @mockgen --source=internal/ports/ports.go --destination=cmd/argo-compare/mocks/interfaces.go --package=mocks 18 | 19 | .PHONY: test 20 | test: mocks ## Run tests 21 | @go test -v ./... -count=1 22 | 23 | .PHONY: test-coverage 24 | test-coverage: mocks ## Run tests with coverage 25 | @mkdir -p .tmp/repo.git/refs/heads .tmp/repo.git/refs/tags 26 | @go test -v -coverprofile=coverage.out ./... -count=1 2>&1 | tee /dev/stderr | go-junit-report -set-exit-code > report.xml 27 | 28 | .PHONY: test-coverage-html 29 | test-coverage-html: test-coverage ## Run tests with coverage and open HTML report 30 | @go tool cover -html=coverage.out -o coverage.html 31 | 32 | .PHONY: ensure-dir 33 | ensure-dir: 34 | @mkdir -p bin 35 | 36 | .PHONY: build 37 | build: ensure-dir ## Build the binary 38 | @CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/argo-compare ./cmd/argo-compare 39 | 40 | .PHONY: clean 41 | clean: ## Remove build artifacts 42 | @rm -rf bin 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.22 AS downloader 2 | 3 | ARG TARGETARCH 4 | 5 | ENV HELM_VERSION=3.15.2 6 | ENV DIFF_SO_FANCY_VERSION=1.4.4 7 | 8 | WORKDIR /tmp 9 | 10 | RUN apk add --no-cache wget git patch \ 11 | && wget --progress=dot:giga -O helm.tar.gz "https://get.helm.sh/helm-v${HELM_VERSION}-linux-${TARGETARCH}.tar.gz" \ 12 | && tar -xf helm.tar.gz "linux-${TARGETARCH}/helm" \ 13 | && mv "linux-${TARGETARCH}/helm" /usr/bin/helm 14 | 15 | RUN git clone -b v${DIFF_SO_FANCY_VERSION} https://github.com/so-fancy/diff-so-fancy /diff-so-fancy \ 16 | && mv /diff-so-fancy/diff-so-fancy /usr/local/bin/diff-so-fancy \ 17 | && mv /diff-so-fancy/lib /usr/local/bin \ 18 | && chmod +x /usr/local/bin/diff-so-fancy 19 | 20 | # We need to apply a patch to the diff-so-fancy to not treat files in different directories as 21 | # renamed files. This is because we need to render manifests 22 | # from source and destination branches to different directories. 23 | COPY patch/diff-so-fancy.patch /tmp/diff-so-fancy.patch 24 | 25 | WORKDIR /usr/local/bin 26 | 27 | RUN patch < /tmp/diff-so-fancy.patch 28 | 29 | FROM alpine:3.22 30 | 31 | RUN apk add --no-cache perl ncurses \ 32 | && adduser --disabled-password --gecos '' app 33 | 34 | COPY --from=downloader /usr/bin/helm /usr/bin/helm 35 | COPY --from=downloader /usr/local/bin/lib /usr/local/bin/lib 36 | COPY --from=downloader /usr/local/bin/diff-so-fancy /usr/local/bin/diff-so-fancy 37 | 38 | COPY argo-compare /bin/argo-compare 39 | 40 | USER app 41 | 42 | ENTRYPOINT ["/bin/argo-compare"] 43 | CMD ["--help"] 44 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | 9 | jobs: 10 | tests: 11 | name: Tests 12 | runs-on: ubuntu-latest 13 | permissions: 14 | checks: write 15 | pull-requests: write 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Fetch target branch 20 | run: | 21 | git fetch origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }} 22 | 23 | - name: Run Gosec Security Scanner 24 | uses: securego/gosec@master 25 | with: 26 | args: ./... 27 | 28 | - uses: actions/setup-go@v4 29 | with: 30 | go-version-file: go.mod 31 | 32 | - name: Load secrets from 1Password 33 | uses: 1password/load-secrets-action@v1 34 | with: 35 | export-env: true 36 | env: 37 | OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} 38 | SONAR_TOKEN: op://github-actions/sonarcloud/token 39 | GITHUB_TOKEN: op://github-actions/github/token 40 | CODECOV_TOKEN: op://github-actions/codecov/argo-compare 41 | 42 | - name: Install project dependencies 43 | run: make install-deps 44 | 45 | - name: Run tests 46 | run: make test-coverage 47 | 48 | - name: Upload coverage reports to Codecov 49 | uses: codecov/codecov-action@v3 50 | with: 51 | token: ${{ env.CODECOV_TOKEN }} 52 | files: coverage.out 53 | -------------------------------------------------------------------------------- /internal/app/diff_strategy_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/op/go-logging" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestExternalDiffStrategyPresent(t *testing.T) { 15 | tmpDir := t.TempDir() 16 | scriptPath := filepath.Join(tmpDir, "collector.sh") 17 | outputPath := filepath.Join(tmpDir, "out.txt") 18 | 19 | script := "#!/bin/sh\ncat >> \"$(dirname \"$0\")/out.txt\"\n" 20 | require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755)) 21 | 22 | logger := logging.MustGetLogger("external-diff") 23 | logging.SetBackend(logging.NewLogBackend(io.Discard, "", 0)) 24 | t.Cleanup(func() { 25 | logging.SetBackend(logging.NewLogBackend(os.Stdout, "", 0)) 26 | }) 27 | 28 | strategy := ExternalDiffStrategy{ 29 | Log: logger, 30 | Tool: scriptPath, 31 | ShowAdded: true, 32 | ShowRemoved: true, 33 | } 34 | 35 | result := ComparisonResult{ 36 | Added: []DiffOutput{ 37 | {File: File{Name: "/added.yaml"}, Diff: "added diff"}, 38 | }, 39 | Removed: []DiffOutput{ 40 | {File: File{Name: "/removed.yaml"}, Diff: "removed diff"}, 41 | }, 42 | Changed: []DiffOutput{ 43 | {File: File{Name: "/changed.yaml"}, Diff: "changed diff"}, 44 | }, 45 | } 46 | 47 | require.NoError(t, strategy.Present(result)) 48 | 49 | content, err := os.ReadFile(outputPath) 50 | require.NoError(t, err) 51 | output := string(content) 52 | 53 | assert.Contains(t, output, "added diff") 54 | assert.Contains(t, output, "removed diff") 55 | assert.Contains(t, output, "changed diff") 56 | } 57 | -------------------------------------------------------------------------------- /internal/ports/ports.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/shini4i/argo-compare/internal/models" 7 | ) 8 | 9 | // CmdRunner executes shell commands and returns captured output. 10 | type CmdRunner interface { 11 | Run(cmd string, args ...string) (stdout string, stderr string, err error) 12 | } 13 | 14 | // OsFs abstracts temporary file creation and removal. 15 | type OsFs interface { 16 | CreateTemp(dir, pattern string) (f *os.File, err error) 17 | Remove(name string) error 18 | } 19 | 20 | // FileReader exposes read access to file contents. 21 | type FileReader interface { 22 | ReadFile(file string) []byte 23 | } 24 | 25 | // Globber expands filesystem patterns into matching paths. 26 | type Globber interface { 27 | Glob(pattern string) ([]string, error) 28 | } 29 | 30 | // SensitiveDataMasker rewrites manifest content to remove or obscure sensitive information. 31 | type SensitiveDataMasker interface { 32 | Mask(content []byte) ([]byte, bool, error) 33 | } 34 | 35 | // HelmChartsProcessor coordinates the Helm chart lifecycle required for comparisons. 36 | type HelmChartsProcessor interface { 37 | GenerateValuesFile(chartName, tmpDir, targetType, values string, valuesObject map[string]interface{}) error 38 | DownloadHelmChart(cmdRunner CmdRunner, globber Globber, cacheDir, repoUrl, chartName, targetRevision string, repoCredentials []models.RepoCredentials) error 39 | ExtractHelmChart(cmdRunner CmdRunner, globber Globber, chartName, chartVersion, chartLocation, tmpDir, targetType string) error 40 | RenderAppSource(cmdRunner CmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType, namespace string) error 41 | } 42 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1761114652, 24 | "narHash": "sha256-f/QCJM/YhrV/lavyCVz8iU3rlZun6d+dAiC3H+CDle4=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "01f116e4df6a15f4ccdffb1bcd41096869fb385c", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shini4i/argo-compare 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/codingsince1985/checksum v1.3.0 9 | github.com/fatih/color v1.18.0 10 | github.com/go-git/go-git/v5 v5.16.3 11 | github.com/hexops/gotextdiff v1.0.3 12 | github.com/mattn/go-zglob v0.0.6 13 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 14 | github.com/spf13/afero v1.14.0 15 | github.com/spf13/cobra v1.10.1 16 | github.com/stretchr/testify v1.10.0 17 | go.uber.org/mock v0.6.0 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | dario.cat/mergo v1.0.0 // indirect 23 | github.com/Microsoft/go-winio v0.6.2 // indirect 24 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 25 | github.com/cloudflare/circl v1.6.1 // indirect 26 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/emirpasic/gods v1.18.1 // indirect 29 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 30 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 31 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 33 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 34 | github.com/kevinburke/ssh_config v1.2.0 // indirect 35 | github.com/mattn/go-colorable v0.1.13 // indirect 36 | github.com/mattn/go-isatty v0.0.20 // indirect 37 | github.com/pjbgf/sha1cd v0.3.2 // indirect 38 | github.com/pmezard/go-difflib v1.0.0 // indirect 39 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 40 | github.com/skeema/knownhosts v1.3.1 // indirect 41 | github.com/spf13/pflag v1.0.9 // indirect 42 | github.com/xanzy/ssh-agent v0.3.3 // indirect 43 | golang.org/x/crypto v0.45.0 // indirect 44 | golang.org/x/net v0.47.0 // indirect 45 | golang.org/x/sys v0.38.0 // indirect 46 | golang.org/x/text v0.31.0 // indirect 47 | gopkg.in/warnings.v0 v0.1.2 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: argo-compare 2 | 3 | before: 4 | hooks: 5 | - make install-deps mocks 6 | - go mod tidy 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm64 17 | mod_timestamp: '{{ .CommitTimestamp }}' 18 | main: ./cmd/argo-compare 19 | 20 | brews: 21 | - repository: 22 | owner: shini4i 23 | name: homebrew-tap 24 | folder: Formula 25 | homepage: https://github.com/shini4i/argo-compare 26 | dependencies: 27 | - name: helm 28 | description: "A comparison tool for displaying the differences between ArgoCD Applications in different Git branches" 29 | license: "MIT" 30 | 31 | dockers: 32 | - image_templates: [ "ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}-amd64" ] 33 | dockerfile: Dockerfile 34 | use: buildx 35 | build_flag_templates: 36 | - "--platform=linux/amd64" 37 | - "--label=org.opencontainers.image.created={{.Date}}" 38 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 39 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 40 | - "--label=org.opencontainers.image.version={{.Version}}" 41 | - "--label=org.opencontainers.image.licenses=MIT" 42 | goos: linux 43 | goarch: amd64 44 | extra_files: 45 | - patch/diff-so-fancy.patch 46 | - image_templates: [ "ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}-arm64" ] 47 | dockerfile: Dockerfile 48 | use: buildx 49 | build_flag_templates: 50 | - "--platform=linux/arm64" 51 | - "--label=org.opencontainers.image.created={{.Date}}" 52 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 53 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 54 | - "--label=org.opencontainers.image.version={{.Version}}" 55 | - "--label=org.opencontainers.image.licenses=MIT" 56 | goos: linux 57 | goarch: arm64 58 | extra_files: 59 | - patch/diff-so-fancy.patch 60 | 61 | docker_manifests: 62 | - name_template: 'ghcr.io/shini4i/{{.ProjectName}}:{{ .Version }}' 63 | image_templates: 64 | - 'ghcr.io/shini4i/{{.ProjectName}}:{{ .Version }}-amd64' 65 | - 'ghcr.io/shini4i/{{.ProjectName}}:{{ .Version }}-arm64' 66 | 67 | snapshot: 68 | name_template: '{{ incpatch .Version }}-dev' 69 | 70 | changelog: 71 | use: 72 | github-native 73 | 74 | release: 75 | prerelease: auto 76 | draft: false 77 | 78 | footer: | 79 | ## Docker Images 80 | - `ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}` 81 | - `ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}-amd64` 82 | - `ghcr.io/shini4i/{{ .ProjectName }}:{{ .Version }}-arm64` 83 | -------------------------------------------------------------------------------- /internal/app/config_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestNewConfigDefaults(t *testing.T) { 12 | cfg, err := NewConfig("main") 13 | require.NoError(t, err) 14 | 15 | assert.Equal(t, "main", cfg.TargetBranch) 16 | assert.Equal(t, os.TempDir(), cfg.TempDirBase) 17 | assert.False(t, cfg.PreserveHelmLabels) 18 | assert.False(t, cfg.PrintAddedManifests) 19 | assert.False(t, cfg.PrintRemovedManifests) 20 | } 21 | 22 | func TestNewConfigWithOptions(t *testing.T) { 23 | cfg, err := NewConfig( 24 | "feature", 25 | WithFileToCompare("app.yaml"), 26 | WithFilesToIgnore([]string{"ignore.yaml"}), 27 | WithPreserveHelmLabels(true), 28 | WithPrintAdded(true), 29 | WithPrintRemoved(true), 30 | WithCacheDir("/tmp/cache"), 31 | WithTempDirBase("/tmp/work"), 32 | WithExternalDiffTool("diff-tool"), 33 | WithDebug(true), 34 | WithVersion("1.2.3"), 35 | ) 36 | require.NoError(t, err) 37 | 38 | assert.Equal(t, "app.yaml", cfg.FileToCompare) 39 | assert.Equal(t, []string{"ignore.yaml"}, cfg.FilesToIgnore) 40 | assert.True(t, cfg.PreserveHelmLabels) 41 | assert.True(t, cfg.PrintAddedManifests) 42 | assert.True(t, cfg.PrintRemovedManifests) 43 | assert.Equal(t, "/tmp/cache", cfg.CacheDir) 44 | assert.Equal(t, "/tmp/work", cfg.TempDirBase) 45 | assert.Equal(t, "diff-tool", cfg.ExternalDiffTool) 46 | assert.True(t, cfg.Debug) 47 | assert.Equal(t, "1.2.3", cfg.Version) 48 | } 49 | 50 | func TestNewConfigRequiresTargetBranch(t *testing.T) { 51 | _, err := NewConfig("") 52 | assert.Error(t, err) 53 | } 54 | 55 | func TestNewConfigWithGitLabComment(t *testing.T) { 56 | cfg, err := NewConfig("main", 57 | WithCacheDir("/tmp/cache"), 58 | WithCommentConfig(CommentConfig{ 59 | Provider: CommentProviderGitLab, 60 | GitLab: GitLabCommentConfig{ 61 | BaseURL: "https://gitlab.example.com", 62 | Token: "secret", 63 | ProjectID: "1", 64 | MergeRequestIID: 42, 65 | }, 66 | }), 67 | ) 68 | require.NoError(t, err) 69 | require.NotNil(t, cfg.Comment) 70 | assert.Equal(t, CommentProviderGitLab, cfg.Comment.Provider) 71 | assert.Equal(t, "https://gitlab.example.com", cfg.Comment.GitLab.BaseURL) 72 | assert.Equal(t, 42, cfg.Comment.GitLab.MergeRequestIID) 73 | } 74 | 75 | func TestNewConfigWithInvalidGitLabComment(t *testing.T) { 76 | _, err := NewConfig("main", 77 | WithCommentConfig(CommentConfig{ 78 | Provider: CommentProviderGitLab, 79 | }), 80 | ) 81 | require.Error(t, err) 82 | } 83 | 84 | func TestNewConfigWithUnsupportedCommentProvider(t *testing.T) { 85 | _, err := NewConfig("main", 86 | WithCommentConfig(CommentConfig{Provider: "bitbucket"}), 87 | ) 88 | require.Error(t, err) 89 | } 90 | -------------------------------------------------------------------------------- /cmd/argo-compare/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/op/go-logging" 8 | command "github.com/shini4i/argo-compare/cmd/argo-compare/command" 9 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 10 | "github.com/shini4i/argo-compare/internal/app" 11 | "github.com/shini4i/argo-compare/internal/helpers" 12 | "github.com/shini4i/argo-compare/internal/sanitizer" 13 | "github.com/spf13/afero" 14 | ) 15 | 16 | const loggerName = "argo-compare" 17 | 18 | var ( 19 | version = "local" 20 | 21 | log = logging.MustGetLogger(loggerName) 22 | format = logging.MustStringFormatter(`%{message}`) 23 | ) 24 | 25 | // loggingInit configures global logging verbosity and output formatting. 26 | func loggingInit(debug bool) { 27 | level := logging.INFO 28 | if debug { 29 | level = logging.DEBUG 30 | } 31 | 32 | backend := logging.NewLogBackend(os.Stdout, "", 0) 33 | backendFormatter := logging.NewBackendFormatter(backend, format) 34 | logging.SetBackend(backendFormatter) 35 | logging.SetLevel(level, "") 36 | } 37 | 38 | // setupDependencies wires runtime collaborators used by the application. 39 | func setupDependencies(logger *logging.Logger) app.Dependencies { 40 | return app.Dependencies{ 41 | FS: afero.NewOsFs(), 42 | CmdRunner: &utils.RealCmdRunner{}, 43 | FileReader: utils.OsFileReader{}, 44 | HelmProcessor: utils.RealHelmChartProcessor{Log: logger}, 45 | Globber: utils.CustomGlobber{}, 46 | Logger: logger, 47 | SensitiveDataMasker: sanitizer.NewKubernetesSecretMasker(), 48 | } 49 | } 50 | 51 | // main is the entry point for the argo-compare CLI binary. 52 | func main() { 53 | opts := buildOptions() 54 | 55 | if err := command.Execute(opts, nil); err != nil { 56 | log.Fatal(err) 57 | } 58 | } 59 | 60 | // buildOptions assembles the command execution options from environment defaults. 61 | func buildOptions() command.Options { 62 | return command.Options{ 63 | Version: version, 64 | CacheDir: resolveCacheDir(), 65 | TempDirBase: os.TempDir(), 66 | ExternalDiffTool: os.Getenv("EXTERNAL_DIFF_TOOL"), 67 | RunApp: runApplication, 68 | InitLogging: loggingInit, 69 | } 70 | } 71 | 72 | // resolveCacheDir determines the cache directory path honoring environment overrides. 73 | func resolveCacheDir() string { 74 | return helpers.GetEnv("ARGO_COMPARE_CACHE_DIR", fmt.Sprintf("%s/.cache/argo-compare", os.Getenv("HOME"))) 75 | } 76 | 77 | // runApplication constructs and executes the application using the supplied configuration. 78 | func runApplication(cfg app.Config) error { 79 | deps := setupDependencies(log) 80 | application, err := app.New(cfg, deps) 81 | if err != nil { 82 | return err 83 | } 84 | return application.Run() 85 | } 86 | -------------------------------------------------------------------------------- /internal/comment/gitlab/poster_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestPosterPost(t *testing.T) { 16 | var received struct { 17 | Method string 18 | Path string 19 | Body map[string]string 20 | Token string 21 | } 22 | 23 | client := &http.Client{ 24 | Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { 25 | received.Method = req.Method 26 | received.Path = req.URL.Path 27 | received.Token = req.Header.Get(privateTokenHeader) 28 | 29 | data, err := io.ReadAll(req.Body) 30 | require.NoError(t, err) 31 | require.NoError(t, json.Unmarshal(data, &received.Body)) 32 | 33 | resp := httptest.NewRecorder() 34 | resp.WriteHeader(http.StatusCreated) 35 | return resp.Result(), nil 36 | }), 37 | } 38 | 39 | cfg := Config{ 40 | BaseURL: "http://gitlab.example", 41 | Token: "token", 42 | ProjectID: "group/project", 43 | MergeRequestIID: 7, 44 | HTTPClient: client, 45 | } 46 | poster, err := NewPoster(cfg) 47 | require.NoError(t, err) 48 | 49 | err = poster.Post("hello world") 50 | require.NoError(t, err) 51 | 52 | assert.Equal(t, http.MethodPost, received.Method) 53 | assert.Equal(t, "token", received.Token) 54 | assert.Equal(t, "hello world", received.Body["body"]) 55 | 56 | baseURL, _ := url.Parse(cfg.BaseURL) 57 | expectedPath := baseURL.Path + "/api/v4/projects/group%2Fproject/merge_requests/7/notes" 58 | assert.Equal(t, expectedPath, received.Path) 59 | } 60 | 61 | func TestPosterPostErrorStatus(t *testing.T) { 62 | client := &http.Client{ 63 | Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { 64 | resp := httptest.NewRecorder() 65 | http.Error(resp, "boom", http.StatusBadRequest) 66 | return resp.Result(), nil 67 | }), 68 | } 69 | 70 | poster, err := NewPoster(Config{ 71 | BaseURL: "https://gitlab.example", 72 | Token: "token", 73 | ProjectID: "1", 74 | MergeRequestIID: 2, 75 | HTTPClient: client, 76 | }) 77 | require.NoError(t, err) 78 | 79 | err = poster.Post("body") 80 | require.Error(t, err) 81 | } 82 | 83 | func TestNewPosterValidatesConfig(t *testing.T) { 84 | _, err := NewPoster(Config{}) 85 | require.Error(t, err) 86 | 87 | _, err = NewPoster(Config{BaseURL: "http://example.com"}) 88 | require.Error(t, err) 89 | 90 | _, err = NewPoster(Config{BaseURL: "http://example.com", Token: "token"}) 91 | require.Error(t, err) 92 | 93 | _, err = NewPoster(Config{BaseURL: "http://example.com", Token: "token", ProjectID: "1"}) 94 | require.Error(t, err) 95 | } 96 | 97 | type roundTripperFunc func(*http.Request) (*http.Response, error) 98 | 99 | func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { 100 | return f(req) 101 | } 102 | -------------------------------------------------------------------------------- /internal/app/app_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "testing" 7 | 8 | "github.com/op/go-logging" 9 | "github.com/shini4i/argo-compare/internal/comment" 10 | "github.com/spf13/afero" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestFilterIgnored(t *testing.T) { 16 | files := []string{"a.yaml", "b.yaml", "c.yaml"} 17 | ignored := []string{"b.yaml"} 18 | 19 | result := filterIgnored(files, ignored) 20 | 21 | assert.Equal(t, []string{"a.yaml", "c.yaml"}, result) 22 | } 23 | 24 | type testPoster struct{} 25 | 26 | func (p *testPoster) Post(string) error { 27 | return nil 28 | } 29 | 30 | func setupTestLogger(t *testing.T, name string) *logging.Logger { 31 | logger := logging.MustGetLogger(name) 32 | logging.SetBackend(logging.NewLogBackend(io.Discard, "", 0)) 33 | t.Cleanup(func() { 34 | logging.SetBackend(logging.NewLogBackend(os.Stdout, "", 0)) 35 | }) 36 | return logger 37 | } 38 | 39 | func TestSelectDiffStrategiesIncludesCommentStrategy(t *testing.T) { 40 | cfg, err := NewConfig("main", 41 | WithCacheDir("/tmp/cache"), 42 | WithCommentConfig(CommentConfig{ 43 | Provider: CommentProviderGitLab, 44 | GitLab: GitLabCommentConfig{ 45 | BaseURL: "https://gitlab.example.com", 46 | Token: "token", 47 | ProjectID: "1", 48 | MergeRequestIID: 101, 49 | }, 50 | }), 51 | ) 52 | require.NoError(t, err) 53 | 54 | logger := setupTestLogger(t, "app-select") 55 | 56 | appInstance, err := New(cfg, Dependencies{ 57 | FS: afero.NewMemMapFs(), 58 | Logger: logger, 59 | CommentPosterFactory: func(Config) (comment.Poster, error) { return &testPoster{}, nil }, 60 | }) 61 | require.NoError(t, err) 62 | 63 | strategies, err := appInstance.selectDiffStrategies("apps/foo.yaml") 64 | require.NoError(t, err) 65 | require.Len(t, strategies, 2) 66 | 67 | _, isStdout := strategies[0].(StdoutStrategy) 68 | assert.True(t, isStdout) 69 | _, isComment := strategies[1].(CommentStrategy) 70 | assert.True(t, isComment) 71 | } 72 | 73 | func TestSelectDiffStrategiesErrorFromFactory(t *testing.T) { 74 | cfg, err := NewConfig("main", 75 | WithCacheDir("/tmp/cache"), 76 | WithCommentConfig(CommentConfig{ 77 | Provider: CommentProviderGitLab, 78 | GitLab: GitLabCommentConfig{ 79 | BaseURL: "https://gitlab.example.com", 80 | Token: "token", 81 | ProjectID: "1", 82 | MergeRequestIID: 101, 83 | }, 84 | }), 85 | ) 86 | require.NoError(t, err) 87 | 88 | logger := setupTestLogger(t, "app-select-error") 89 | 90 | appInstance, err := New(cfg, Dependencies{ 91 | FS: afero.NewMemMapFs(), 92 | Logger: logger, 93 | CommentPosterFactory: func(Config) (comment.Poster, error) { return nil, assert.AnError }, 94 | }) 95 | require.NoError(t, err) 96 | 97 | _, err = appInstance.selectDiffStrategies("apps/foo.yaml") 98 | require.Error(t, err) 99 | } 100 | -------------------------------------------------------------------------------- /internal/app/diff_strategy.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strings" 7 | 8 | "github.com/op/go-logging" 9 | ) 10 | 11 | // DiffStrategy presents comparison results to the user. 12 | type DiffStrategy interface { 13 | Present(result ComparisonResult) error 14 | } 15 | 16 | const currentFilePrintPattern = "▶ %s" 17 | 18 | // StdoutStrategy writes diff summaries to the configured logger. 19 | type StdoutStrategy struct { 20 | Log *logging.Logger 21 | ShowAdded bool 22 | ShowRemoved bool 23 | } 24 | 25 | // ExternalDiffStrategy pipes unified diffs into an external command. 26 | type ExternalDiffStrategy struct { 27 | Log *logging.Logger 28 | Tool string 29 | ShowAdded bool 30 | ShowRemoved bool 31 | } 32 | 33 | // Present prints comparison results using the configured stdout logger. 34 | func (s StdoutStrategy) Present(result ComparisonResult) error { 35 | if result.IsEmpty() { 36 | s.Log.Info("No diff was found in rendered manifests!") 37 | return nil 38 | } 39 | 40 | if s.ShowAdded { 41 | s.printSection("added", result.Added) 42 | } 43 | 44 | if s.ShowRemoved { 45 | s.printSection("removed", result.Removed) 46 | } 47 | 48 | s.printSection("changed", result.Changed) 49 | 50 | return nil 51 | } 52 | 53 | // printSection logs a summary of diff entries and prints their unified diffs. 54 | func (s StdoutStrategy) printSection(operation string, entries []DiffOutput) { 55 | if len(entries) == 0 { 56 | return 57 | } 58 | 59 | fileText := "file" 60 | if len(entries) > 1 { 61 | fileText = "files" 62 | } 63 | 64 | s.Log.Infof("The following %d %s would be %s:", len(entries), fileText, operation) 65 | 66 | for _, entry := range entries { 67 | s.Log.Infof(currentFilePrintPattern, entry.File.Name) 68 | fmt.Println(entry.Diff) 69 | } 70 | } 71 | 72 | // Present streams diff content to the configured external tool. 73 | func (s ExternalDiffStrategy) Present(result ComparisonResult) error { 74 | if result.IsEmpty() { 75 | s.Log.Info("No diff was found in rendered manifests!") 76 | return nil 77 | } 78 | 79 | if s.ShowAdded { 80 | if err := s.runSection(result.Added); err != nil { 81 | return err 82 | } 83 | } 84 | 85 | if s.ShowRemoved { 86 | if err := s.runSection(result.Removed); err != nil { 87 | return err 88 | } 89 | } 90 | 91 | return s.runSection(result.Changed) 92 | } 93 | 94 | // runSection streams a set of diff outputs through the configured external diff tool. 95 | func (s ExternalDiffStrategy) runSection(entries []DiffOutput) error { 96 | for _, entry := range entries { 97 | if err := s.runTool(entry.Diff); err != nil { 98 | s.Log.Errorf("External diff tool failed for %s: %v", entry.File.Name, err) 99 | } 100 | } 101 | return nil 102 | } 103 | 104 | // runTool executes the external diff command with the given diff content. 105 | func (s ExternalDiffStrategy) runTool(diff string) error { 106 | cmd := exec.Command(s.Tool) // #nosec G204 107 | cmd.Stdin = strings.NewReader(diff) 108 | 109 | output, err := cmd.CombinedOutput() 110 | if len(output) > 0 { 111 | fmt.Println(string(output)) 112 | } 113 | 114 | return err 115 | } 116 | -------------------------------------------------------------------------------- /internal/models/application.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | // NotApplicationError signals that the provided manifest is not an ArgoCD Application. 10 | NotApplicationError = errors.New("file is not an Application") 11 | // UnsupportedAppConfigurationError identifies manifests that use unsupported configuration. 12 | UnsupportedAppConfigurationError = errors.New("unsupported Application configuration") 13 | // EmptyFileError indicates that the manifest file contained no data. 14 | EmptyFileError = errors.New("file is empty") 15 | ) 16 | 17 | // Application models the subset of ArgoCD Application fields used by the tool. 18 | type Application struct { 19 | Kind string `yaml:"kind"` 20 | Metadata struct { 21 | Name string `yaml:"name"` 22 | Namespace string `yaml:"namespace"` 23 | } `yaml:"metadata"` 24 | Spec struct { 25 | Source *Source `yaml:"source"` 26 | Sources []*Source `yaml:"sources"` 27 | MultiSource bool `yaml:"-"` 28 | Destination *Destination `yaml:"destination"` 29 | } `yaml:"spec"` 30 | } 31 | 32 | // Destination describes where an Application should be deployed. 33 | type Destination struct { 34 | Server string `yaml:"server"` 35 | Namespace string `yaml:"namespace"` 36 | } 37 | 38 | // Source holds the chart or path information for a single Application source. 39 | type Source struct { 40 | RepoURL string `yaml:"repoURL"` 41 | Chart string `yaml:"chart,omitempty"` 42 | TargetRevision string `yaml:"targetRevision"` 43 | Path string `yaml:"path,omitempty"` 44 | Helm struct { 45 | ReleaseName string `yaml:"releaseName,omitempty"` 46 | Values string `yaml:"values,omitempty"` 47 | ValueFiles []string `yaml:"valueFiles,omitempty"` 48 | ValuesObject map[string]interface{} `yaml:"valuesObject,omitempty"` 49 | } `yaml:"helm"` 50 | } 51 | 52 | // Validate performs validation checks on the Application struct. 53 | // It checks for the following: 54 | // - If the Application struct is empty, returns EmptyFileError. 55 | // - If both the 'source' and 'sources' fields are set at the same time, returns an error. 56 | // - If the kind of the application is not "Application", returns NotApplicationError. 57 | // - If the application specifies sources, ensures that each source has a non-empty 'chart' field. 58 | // - Sets the 'MultiSource' field to true if sources are specified. 59 | // - Returns nil if all validation checks pass. 60 | func (app *Application) Validate() error { 61 | // Check if the required fields 'Kind', 'Metadata.Name', and 'Metadata.Namespace' are set. 62 | if app.Kind == "" && app.Metadata.Name == "" && app.Metadata.Namespace == "" { 63 | return EmptyFileError 64 | } 65 | 66 | if app.Spec.Source != nil && len(app.Spec.Sources) > 0 { 67 | return fmt.Errorf("both 'source' and 'sources' fields cannot be set at the same time") 68 | } 69 | 70 | if app.Kind != "Application" { 71 | return NotApplicationError 72 | } 73 | 74 | // currently we support only helm repository based charts as a source 75 | if len(app.Spec.Sources) != 0 { 76 | for _, source := range app.Spec.Sources { 77 | if len(source.Chart) == 0 { 78 | return UnsupportedAppConfigurationError 79 | } 80 | } 81 | } else { 82 | if len(app.Spec.Source.Chart) == 0 { 83 | return UnsupportedAppConfigurationError 84 | } 85 | } 86 | 87 | if app.Spec.Sources != nil { 88 | app.Spec.MultiSource = true 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/shini4i/argo-compare/internal/models" 10 | "github.com/spf13/afero" 11 | ) 12 | 13 | // GetEnv retrieves the value of an environment variable specified by the given key. 14 | // If the environment variable is set, its value is returned. 15 | // If the environment variable is not set, the provided fallback value is returned. 16 | func GetEnv(key, fallback string) string { 17 | if value, ok := os.LookupEnv(key); ok { 18 | return value 19 | } 20 | return fallback 21 | } 22 | 23 | // StripHelmLabels removes the specified Helm labels from the content of a file. 24 | // The function takes a file path as input and returns the stripped file content as a byte slice. 25 | // It removes the labels listed in the `labels` slice using regular expressions. 26 | // The function returns an error if there is an issue reading the file. 27 | func StripHelmLabels(file string) ([]byte, error) { 28 | // list of labels to remove 29 | labels := []string{ 30 | "app.kubernetes.io/managed-by", 31 | "helm.sh/chart", 32 | "chart", 33 | "app.kubernetes.io/version", 34 | } 35 | 36 | regex := strings.Join(labels, "|") 37 | 38 | // remove helm labels as they are not needed for comparison 39 | // it might be error-prone, as those labels are not always the same 40 | re := regexp.MustCompile("(?m)[\r\n]+^.*(" + regex + "):.*$") 41 | 42 | var fileData []byte 43 | var err error 44 | 45 | if fileData, err = os.ReadFile(file); err != nil /* #nosec G304 */ { 46 | return nil, err 47 | } 48 | 49 | strippedFileData := re.ReplaceAll(fileData, []byte("")) 50 | 51 | return strippedFileData, nil 52 | } 53 | 54 | // WriteToFile writes the provided data to a file specified by the file path. 55 | // It takes a file path and a byte slice of data as input. 56 | // The function writes the data to the file with the specified file permissions (0644). 57 | // It returns an error if there is an issue writing to the file. 58 | func WriteToFile(fs afero.Fs, file string, data []byte) error { 59 | if err := afero.WriteFile(fs, file, data, 0644); err != nil { 60 | return err 61 | } 62 | return nil 63 | } 64 | 65 | // CreateTempFile creates a temporary file in the "/tmp" directory with a unique name 66 | // that has the prefix "compare-" and suffix ".yaml". It then writes the provided content 67 | // to this temporary file. The function returns a pointer to the created os.File if it 68 | // succeeds. If the function fails at any step, it returns an error wrapped with context 69 | // about what step of the process it failed at. 70 | func CreateTempFile(fs afero.Fs, content string) (afero.File, error) { 71 | tmpFile, err := afero.TempFile(fs, "/tmp", "compare-*.yaml") 72 | if err != nil { 73 | return nil, fmt.Errorf("failed to create temporary file: %w", err) 74 | } 75 | 76 | if err = WriteToFile(fs, tmpFile.Name(), []byte(content)); err != nil { 77 | return nil, fmt.Errorf("failed to write to temporary file: %w", err) 78 | } 79 | 80 | return tmpFile, nil 81 | } 82 | 83 | // FindHelmRepoCredentials scans the provided array of RepoCredentials for a match to the 84 | // provided repository URL, and returns the associated username and password. 85 | // If no matching credentials are found, it returns two empty strings. 86 | func FindHelmRepoCredentials(url string, credentials []models.RepoCredentials) (string, string) { 87 | for _, repoCred := range credentials { 88 | if repoCred.Url == url { 89 | return repoCred.Username, repoCred.Password 90 | } 91 | } 92 | return "", "" 93 | } 94 | -------------------------------------------------------------------------------- /internal/comment/gitlab/poster.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "path" 11 | "strings" 12 | "time" 13 | 14 | "github.com/shini4i/argo-compare/internal/comment" 15 | ) 16 | 17 | const ( 18 | defaultAPIPrefix = "/api/v4" 19 | privateTokenHeader = "PRIVATE-TOKEN" 20 | maxErrorResponseBytes = 4096 21 | defaultClientTimeout = 15 * time.Second 22 | ) 23 | 24 | // Config describes settings required to post comments to a GitLab Merge Request. 25 | type Config struct { 26 | BaseURL string 27 | Token string 28 | ProjectID string 29 | MergeRequestIID int 30 | HTTPClient *http.Client 31 | APIPrefix string 32 | Timeout time.Duration 33 | } 34 | 35 | type Poster struct { 36 | client *http.Client 37 | baseURL *url.URL 38 | projectID string 39 | mergeRequestIID int 40 | token string 41 | apiPrefix string 42 | } 43 | 44 | // Ensure Poster implements comment.Poster. 45 | var _ comment.Poster = (*Poster)(nil) 46 | 47 | // NewPoster builds a GitLab Merge Request comment poster. 48 | func NewPoster(cfg Config) (*Poster, error) { 49 | if cfg.BaseURL == "" { 50 | return nil, fmt.Errorf("gitlab: base URL is required") 51 | } 52 | if cfg.Token == "" { 53 | return nil, fmt.Errorf("gitlab: token is required") 54 | } 55 | if cfg.ProjectID == "" { 56 | return nil, fmt.Errorf("gitlab: project ID is required") 57 | } 58 | if cfg.MergeRequestIID == 0 { 59 | return nil, fmt.Errorf("gitlab: merge request IID is required") 60 | } 61 | 62 | base, err := url.Parse(cfg.BaseURL) 63 | if err != nil { 64 | return nil, fmt.Errorf("gitlab: parse base URL: %w", err) 65 | } 66 | 67 | client := cfg.HTTPClient 68 | if client == nil { 69 | timeout := cfg.Timeout 70 | if timeout <= 0 { 71 | timeout = defaultClientTimeout 72 | } 73 | client = &http.Client{Timeout: timeout} 74 | } 75 | 76 | apiPrefix := cfg.APIPrefix 77 | if apiPrefix == "" { 78 | apiPrefix = defaultAPIPrefix 79 | } 80 | 81 | return &Poster{ 82 | client: client, 83 | baseURL: base, 84 | projectID: cfg.ProjectID, 85 | mergeRequestIID: cfg.MergeRequestIID, 86 | token: cfg.Token, 87 | apiPrefix: apiPrefix, 88 | }, nil 89 | } 90 | 91 | // Post sends the supplied comment body to the configured Merge Request note endpoint. 92 | func (p *Poster) Post(body string) error { 93 | if strings.TrimSpace(body) == "" { 94 | return fmt.Errorf("gitlab: comment body is empty") 95 | } 96 | 97 | endpoint := *p.baseURL 98 | endpoint.Path = path.Join(endpoint.Path, p.apiPrefix, "projects", url.PathEscape(p.projectID), "merge_requests", fmt.Sprintf("%d", p.mergeRequestIID), "notes") 99 | 100 | payload, err := json.Marshal(map[string]string{"body": body}) 101 | if err != nil { 102 | return fmt.Errorf("gitlab: marshal payload: %w", err) 103 | } 104 | 105 | req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(payload)) 106 | if err != nil { 107 | return fmt.Errorf("gitlab: build request: %w", err) 108 | } 109 | req.Header.Set("Content-Type", "application/json") 110 | req.Header.Set(privateTokenHeader, p.token) 111 | 112 | resp, err := p.client.Do(req) 113 | if err != nil { 114 | return fmt.Errorf("gitlab: perform request: %w", err) 115 | } 116 | defer resp.Body.Close() 117 | 118 | if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { 119 | respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorResponseBytes)) 120 | return fmt.Errorf("gitlab: unexpected status %s: %s", resp.Status, strings.TrimSpace(string(respBody))) 121 | } 122 | 123 | // Drain response body for connection reuse; errors are intentionally ignored. 124 | _, _ = io.Copy(io.Discard, resp.Body) 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /internal/models/application_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestApplication_Validate(t *testing.T) { 11 | // Test case 1: Empty application 12 | app := &Application{} 13 | err := app.Validate() 14 | assert.True(t, errors.Is(err, EmptyFileError), "Expected validation error: %v, but got: %v", EmptyFileError, err) 15 | 16 | // Test case 2: Application with invalid kind 17 | app = &Application{ 18 | Kind: "InvalidKind", 19 | } 20 | err = app.Validate() 21 | assert.True(t, errors.Is(err, NotApplicationError), "Expected validation error: %v, but got: %v", NotApplicationError, err) 22 | 23 | // Test case 3: Unsupported app configuration - empty chart name 24 | appWithEmptyChart := &Application{ 25 | Kind: "Application", 26 | Spec: struct { 27 | Source *Source `yaml:"source"` 28 | Sources []*Source `yaml:"sources"` 29 | MultiSource bool `yaml:"-"` 30 | Destination *Destination `yaml:"destination"` 31 | }{ 32 | Source: &Source{ 33 | Chart: "", // Empty chart name 34 | }, 35 | Sources: nil, 36 | MultiSource: false, 37 | }, 38 | } 39 | err = appWithEmptyChart.Validate() 40 | assert.ErrorIs(t, err, UnsupportedAppConfigurationError, "expected UnsupportedAppConfigurationError") 41 | 42 | // Test case 4: Valid application with multiple sources 43 | appWithMultipleSources := &Application{ 44 | Kind: "Application", 45 | Spec: struct { 46 | Source *Source `yaml:"source"` 47 | Sources []*Source `yaml:"sources"` 48 | MultiSource bool `yaml:"-"` 49 | Destination *Destination `yaml:"destination"` 50 | }{ 51 | Source: nil, 52 | Sources: []*Source{ 53 | { 54 | RepoURL: "https://chart.example.com", 55 | Chart: "chart-1", 56 | TargetRevision: "1.0.0", 57 | }, 58 | { 59 | RepoURL: "https://chart.example.com", 60 | Chart: "chart-2", 61 | TargetRevision: "2.0.0", 62 | }, 63 | }, 64 | MultiSource: false, 65 | }, 66 | } 67 | err = appWithMultipleSources.Validate() 68 | assert.NoError(t, err, "expected no error") 69 | 70 | // Test case 5: Both 'source' and 'sources' fields are set 71 | appWithBothFields := &Application{ 72 | Kind: "Application", 73 | Spec: struct { 74 | Source *Source `yaml:"source"` 75 | Sources []*Source `yaml:"sources"` 76 | MultiSource bool `yaml:"-"` 77 | Destination *Destination `yaml:"destination"` 78 | }{ 79 | Source: &Source{ 80 | RepoURL: "https://chart.example.com", 81 | Chart: "ingress-nginx", 82 | TargetRevision: "3.34.0", 83 | }, 84 | Sources: []*Source{ 85 | { 86 | RepoURL: "https://chart.example.com", 87 | Chart: "chart-1", 88 | TargetRevision: "1.0.0", 89 | }, 90 | }, 91 | MultiSource: false, 92 | }, 93 | } 94 | err = appWithBothFields.Validate() 95 | assert.EqualError(t, err, "both 'source' and 'sources' fields cannot be set at the same time", "expected error message") 96 | 97 | // Test case 6: Unsupported app configuration - empty chart name in multiple sources 98 | appWithMultipleSourcesUnsupported := &Application{ 99 | Kind: "Application", 100 | Spec: struct { 101 | Source *Source `yaml:"source"` 102 | Sources []*Source `yaml:"sources"` 103 | MultiSource bool `yaml:"-"` 104 | Destination *Destination `yaml:"destination"` 105 | }{ 106 | Source: nil, 107 | Sources: []*Source{ 108 | { 109 | Chart: "", 110 | }, 111 | { 112 | Chart: "chart-2", 113 | }, 114 | }, 115 | MultiSource: false, 116 | }, 117 | } 118 | err = appWithMultipleSourcesUnsupported.Validate() 119 | assert.ErrorIs(t, err, UnsupportedAppConfigurationError, "expected UnsupportedAppConfigurationError") 120 | } 121 | -------------------------------------------------------------------------------- /internal/sanitizer/secret_masker_test.go: -------------------------------------------------------------------------------- 1 | package sanitizer 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | // TestKubernetesSecretMasker_MaskRedactsSecretValues ensures secret manifests have their sensitive values masked. 12 | func TestKubernetesSecretMasker_MaskRedactsSecretValues(t *testing.T) { 13 | masker := NewKubernetesSecretMasker() 14 | input := `apiVersion: v1 15 | kind: Secret 16 | metadata: 17 | name: sample 18 | data: 19 | password: c2VjcmV0cGFzc3dvcmQ= 20 | stringData: 21 | token: plain-token 22 | ` 23 | 24 | result, masked, err := masker.Mask([]byte(input)) 25 | require.NoError(t, err) 26 | assert.True(t, masked) 27 | 28 | output := string(result) 29 | assert.NotContains(t, output, "c2VjcmV0cGFzc3dvcmQ=") 30 | assert.NotContains(t, output, "plain-token") 31 | assert.Contains(t, output, "ENC[sha256:") 32 | 33 | again, maskedAgain, err := masker.Mask([]byte(input)) 34 | require.NoError(t, err) 35 | assert.True(t, maskedAgain) 36 | assert.Equal(t, string(result), string(again)) 37 | } 38 | 39 | // TestKubernetesSecretMasker_MaskLeavesNonSecrets ensures non-secret manifests remain unchanged. 40 | func TestKubernetesSecretMasker_MaskLeavesNonSecrets(t *testing.T) { 41 | masker := NewKubernetesSecretMasker() 42 | input := `apiVersion: v1 43 | kind: ConfigMap 44 | metadata: 45 | name: example 46 | data: 47 | key: value 48 | ` 49 | 50 | result, masked, err := masker.Mask([]byte(input)) 51 | require.NoError(t, err) 52 | assert.False(t, masked) 53 | assert.Equal(t, input, string(result)) 54 | } 55 | 56 | // TestKubernetesSecretMasker_MaskHandlesMultipleDocuments ensures multi-document YAML is masked correctly. 57 | func TestKubernetesSecretMasker_MaskHandlesMultipleDocuments(t *testing.T) { 58 | masker := NewKubernetesSecretMasker() 59 | input := `apiVersion: v1 60 | kind: Secret 61 | metadata: 62 | name: first 63 | data: 64 | password: c2VjcmV0 65 | --- 66 | apiVersion: v1 67 | kind: ConfigMap 68 | metadata: 69 | name: config 70 | data: 71 | key: value 72 | --- 73 | apiVersion: v1 74 | kind: Secret 75 | metadata: 76 | name: second 77 | stringData: 78 | api-key: another value 79 | ` 80 | 81 | result, masked, err := masker.Mask([]byte(input)) 82 | require.NoError(t, err) 83 | assert.True(t, masked) 84 | 85 | output := string(result) 86 | assert.NotContains(t, output, "c2VjcmV0") 87 | assert.NotContains(t, output, "another value") 88 | assert.Equal(t, 2, strings.Count(output, "ENC[sha256:")) 89 | } 90 | 91 | // TestKubernetesSecretMasker_MaskDifferentiatesValues ensures masked placeholders differ for distinct inputs. 92 | func TestKubernetesSecretMasker_MaskDifferentiatesValues(t *testing.T) { 93 | masker := NewKubernetesSecretMasker() 94 | 95 | first := `apiVersion: v1 96 | kind: Secret 97 | metadata: 98 | name: sample 99 | data: 100 | password: c2VjcmV0 101 | ` 102 | second := `apiVersion: v1 103 | kind: Secret 104 | metadata: 105 | name: sample 106 | data: 107 | password: ZGlmZmVyZW50 108 | ` 109 | 110 | resultOne, maskedOne, err := masker.Mask([]byte(first)) 111 | require.NoError(t, err) 112 | assert.True(t, maskedOne) 113 | 114 | resultTwo, maskedTwo, err := masker.Mask([]byte(second)) 115 | require.NoError(t, err) 116 | assert.True(t, maskedTwo) 117 | 118 | assert.NotEqual(t, string(resultOne), string(resultTwo)) 119 | assert.Contains(t, string(resultOne), "ENC[sha256:") 120 | assert.Contains(t, string(resultTwo), "ENC[sha256:") 121 | } 122 | 123 | // TestKubernetesSecretMasker_HandlesNonMappingData ensures non-mapping secret data blocks are ignored safely. 124 | func TestKubernetesSecretMasker_HandlesNonMappingData(t *testing.T) { 125 | masker := NewKubernetesSecretMasker() 126 | input := `apiVersion: v1 127 | kind: Secret 128 | metadata: 129 | name: sample 130 | data: [] 131 | ` 132 | 133 | result, masked, err := masker.Mask([]byte(input)) 134 | require.NoError(t, err) 135 | assert.False(t, masked) 136 | assert.Equal(t, input, string(result)) 137 | } 138 | -------------------------------------------------------------------------------- /internal/app/target_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/op/go-logging" 7 | "github.com/shini4i/argo-compare/internal/models" 8 | "github.com/shini4i/argo-compare/internal/ports" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type recordingHelmProcessor struct { 14 | generateValuesCalls int 15 | downloadCalls int 16 | extractCalls int 17 | renderCalls int 18 | } 19 | 20 | func (r *recordingHelmProcessor) GenerateValuesFile(chartName, tmpDir, targetType, values string, valuesObject map[string]interface{}) error { 21 | r.generateValuesCalls++ 22 | return nil 23 | } 24 | 25 | func (r *recordingHelmProcessor) DownloadHelmChart(cmdRunner ports.CmdRunner, globber ports.Globber, cacheDir, repoUrl, chartName, targetRevision string, repoCredentials []models.RepoCredentials) error { 26 | r.downloadCalls++ 27 | return nil 28 | } 29 | 30 | func (r *recordingHelmProcessor) ExtractHelmChart(cmdRunner ports.CmdRunner, globber ports.Globber, chartName, chartVersion, chartLocation, tmpDir, targetType string) error { 31 | r.extractCalls++ 32 | return nil 33 | } 34 | 35 | func (r *recordingHelmProcessor) RenderAppSource(cmdRunner ports.CmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType, namespace string) error { 36 | r.renderCalls++ 37 | return nil 38 | } 39 | 40 | type noopCmdRunner struct{} 41 | 42 | func (noopCmdRunner) Run(string, ...string) (string, string, error) { return "", "", nil } 43 | 44 | type noopFileReader struct{} 45 | 46 | func (noopFileReader) ReadFile(string) []byte { return nil } 47 | 48 | func TestTargetMultiSourceInvokesHelmPerSource(t *testing.T) { 49 | processor := &recordingHelmProcessor{} 50 | 51 | target := Target{ 52 | CmdRunner: noopCmdRunner{}, 53 | FileReader: noopFileReader{}, 54 | HelmProcessor: processor, 55 | CacheDir: "cache", 56 | TmpDir: "tmp", 57 | RepoCredentials: nil, 58 | Log: logging.MustGetLogger("target-test"), 59 | Type: "src", 60 | App: models.Application{ 61 | Spec: struct { 62 | Source *models.Source `yaml:"source"` 63 | Sources []*models.Source `yaml:"sources"` 64 | MultiSource bool `yaml:"-"` 65 | Destination *models.Destination `yaml:"destination"` 66 | }{ 67 | Sources: []*models.Source{ 68 | { 69 | RepoURL: "repoA", 70 | Chart: "chartA", 71 | TargetRevision: "1.0.0", 72 | Helm: struct { 73 | ReleaseName string `yaml:"releaseName,omitempty"` 74 | Values string `yaml:"values,omitempty"` 75 | ValueFiles []string `yaml:"valueFiles,omitempty"` 76 | ValuesObject map[string]interface{} `yaml:"valuesObject,omitempty"` 77 | }{ 78 | ReleaseName: "releaseA", 79 | Values: "replicaCount: 1", 80 | }, 81 | }, 82 | { 83 | RepoURL: "repoB", 84 | Chart: "chartB", 85 | TargetRevision: "2.0.0", 86 | Helm: struct { 87 | ReleaseName string `yaml:"releaseName,omitempty"` 88 | Values string `yaml:"values,omitempty"` 89 | ValueFiles []string `yaml:"valueFiles,omitempty"` 90 | ValuesObject map[string]interface{} `yaml:"valuesObject,omitempty"` 91 | }{ 92 | ValuesObject: map[string]interface{}{"replicaCount": 3}, 93 | }, 94 | }, 95 | }, 96 | MultiSource: true, 97 | Destination: &models.Destination{Namespace: "demo"}, 98 | }, 99 | }, 100 | } 101 | 102 | require.NoError(t, target.generateValuesFiles()) 103 | require.NoError(t, target.ensureHelmCharts()) 104 | require.NoError(t, target.extractCharts()) 105 | require.NoError(t, target.renderAppSources()) 106 | 107 | assert.Equal(t, 2, processor.generateValuesCalls) 108 | assert.Equal(t, 2, processor.downloadCalls) 109 | assert.Equal(t, 2, processor.extractCalls) 110 | assert.Equal(t, 2, processor.renderCalls) 111 | } 112 | -------------------------------------------------------------------------------- /internal/app/target.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/op/go-logging" 8 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 9 | "github.com/shini4i/argo-compare/internal/models" 10 | "github.com/shini4i/argo-compare/internal/ports" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | // Target encapsulates the chart rendering workflow for a single application source. 15 | type Target struct { 16 | CmdRunner ports.CmdRunner 17 | FileReader ports.FileReader 18 | HelmProcessor ports.HelmChartsProcessor 19 | CacheDir string 20 | TmpDir string 21 | RepoCredentials []models.RepoCredentials 22 | Log *logging.Logger 23 | 24 | File string 25 | Type string 26 | App models.Application 27 | } 28 | 29 | // parse loads the target application's manifest into memory and validates its structure. 30 | func (t *Target) parse() error { 31 | app := models.Application{} 32 | 33 | var file string 34 | 35 | if !strings.Contains(t.File, "/tmp/") { 36 | gitRepoRoot, err := GetGitRepoRoot() 37 | if err != nil { 38 | return err 39 | } 40 | file = fmt.Sprintf("%s/%s", gitRepoRoot, t.File) 41 | } else { 42 | file = t.File 43 | } 44 | 45 | t.Log.Debugf("Parsing %s...", file) 46 | 47 | yamlContent := t.FileReader.ReadFile(file) 48 | if err := yaml.Unmarshal(yamlContent, &app); err != nil { 49 | return err 50 | } 51 | 52 | if err := app.Validate(); err != nil { 53 | return err 54 | } 55 | 56 | t.App = app 57 | 58 | return nil 59 | } 60 | 61 | // generateValuesFiles materializes Helm values files so templates can be rendered. 62 | func (t *Target) generateValuesFiles() error { 63 | if t.App.Spec.MultiSource { 64 | for _, source := range t.App.Spec.Sources { 65 | if err := t.HelmProcessor.GenerateValuesFile(source.Chart, t.TmpDir, t.Type, source.Helm.Values, source.Helm.ValuesObject); err != nil { 66 | return err 67 | } 68 | } 69 | return nil 70 | } 71 | 72 | return t.HelmProcessor.GenerateValuesFile( 73 | t.App.Spec.Source.Chart, 74 | t.TmpDir, 75 | t.Type, 76 | t.App.Spec.Source.Helm.Values, 77 | t.App.Spec.Source.Helm.ValuesObject, 78 | ) 79 | } 80 | 81 | // ensureHelmCharts downloads required Helm charts into the configured cache. 82 | func (t *Target) ensureHelmCharts() error { 83 | if t.App.Spec.MultiSource { 84 | for _, source := range t.App.Spec.Sources { 85 | if err := t.HelmProcessor.DownloadHelmChart( 86 | t.CmdRunner, 87 | utils.CustomGlobber{}, 88 | t.CacheDir, 89 | source.RepoURL, 90 | source.Chart, 91 | source.TargetRevision, 92 | t.RepoCredentials, 93 | ); err != nil { 94 | return err 95 | } 96 | } 97 | return nil 98 | } 99 | 100 | return t.HelmProcessor.DownloadHelmChart( 101 | t.CmdRunner, 102 | utils.CustomGlobber{}, 103 | t.CacheDir, 104 | t.App.Spec.Source.RepoURL, 105 | t.App.Spec.Source.Chart, 106 | t.App.Spec.Source.TargetRevision, 107 | t.RepoCredentials, 108 | ) 109 | } 110 | 111 | // extractCharts unpacks cached Helm charts into the working directories. 112 | func (t *Target) extractCharts() error { 113 | if t.App.Spec.MultiSource { 114 | for _, source := range t.App.Spec.Sources { 115 | if err := t.HelmProcessor.ExtractHelmChart( 116 | t.CmdRunner, 117 | utils.CustomGlobber{}, 118 | source.Chart, 119 | source.TargetRevision, 120 | fmt.Sprintf("%s/%s", t.CacheDir, source.RepoURL), 121 | t.TmpDir, 122 | t.Type, 123 | ); err != nil { 124 | return err 125 | } 126 | } 127 | return nil 128 | } 129 | 130 | return t.HelmProcessor.ExtractHelmChart( 131 | t.CmdRunner, 132 | utils.CustomGlobber{}, 133 | t.App.Spec.Source.Chart, 134 | t.App.Spec.Source.TargetRevision, 135 | fmt.Sprintf("%s/%s", t.CacheDir, t.App.Spec.Source.RepoURL), 136 | t.TmpDir, 137 | t.Type, 138 | ) 139 | } 140 | 141 | // renderAppSources runs Helm template rendering for each application source. 142 | func (t *Target) renderAppSources() error { 143 | var releaseName string 144 | 145 | if !t.App.Spec.MultiSource { 146 | if t.App.Spec.Source.Helm.ReleaseName != "" { 147 | releaseName = t.App.Spec.Source.Helm.ReleaseName 148 | } else { 149 | releaseName = t.App.Metadata.Name 150 | } 151 | } 152 | 153 | if t.App.Spec.MultiSource { 154 | for _, source := range t.App.Spec.Sources { 155 | if source.Helm.ReleaseName != "" { 156 | releaseName = source.Helm.ReleaseName 157 | } else { 158 | releaseName = t.App.Metadata.Name 159 | } 160 | if err := t.HelmProcessor.RenderAppSource( 161 | t.CmdRunner, 162 | releaseName, 163 | source.Chart, 164 | source.TargetRevision, 165 | t.TmpDir, 166 | t.Type, 167 | t.App.Spec.Destination.Namespace, 168 | ); err != nil { 169 | return err 170 | } 171 | } 172 | return nil 173 | } 174 | 175 | return t.HelmProcessor.RenderAppSource( 176 | t.CmdRunner, 177 | releaseName, 178 | t.App.Spec.Source.Chart, 179 | t.App.Spec.Source.TargetRevision, 180 | t.TmpDir, 181 | t.Type, 182 | t.App.Spec.Destination.Namespace, 183 | ) 184 | } 185 | -------------------------------------------------------------------------------- /internal/app/config.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // Config captures runtime parameters for a comparison run. 10 | type Config struct { 11 | TargetBranch string 12 | FileToCompare string 13 | FilesToIgnore []string 14 | PreserveHelmLabels bool 15 | PrintAddedManifests bool 16 | PrintRemovedManifests bool 17 | CacheDir string 18 | TempDirBase string 19 | ExternalDiffTool string 20 | Debug bool 21 | Version string 22 | Comment *CommentConfig 23 | } 24 | 25 | // ConfigOption mutates a Config during construction. 26 | type ConfigOption func(*Config) 27 | 28 | // NewConfig creates a Config with defaults and applies provided options. 29 | func NewConfig(targetBranch string, opts ...ConfigOption) (Config, error) { 30 | if targetBranch == "" { 31 | return Config{}, errors.New("target branch must be provided") 32 | } 33 | 34 | cfg := Config{ 35 | TargetBranch: targetBranch, 36 | TempDirBase: os.TempDir(), 37 | } 38 | 39 | for _, opt := range opts { 40 | opt(&cfg) 41 | } 42 | 43 | if cfg.Comment != nil { 44 | if err := cfg.Comment.validate(); err != nil { 45 | return Config{}, err 46 | } 47 | } 48 | 49 | return cfg, nil 50 | } 51 | 52 | // CommentProvider identifies the upstream system where diffs can be posted as comments. 53 | type CommentProvider string 54 | 55 | const ( 56 | // CommentProviderNone disables comment publishing. 57 | CommentProviderNone CommentProvider = "" 58 | // CommentProviderGitLab enables posting comments to GitLab merge requests. 59 | CommentProviderGitLab CommentProvider = "gitlab" 60 | ) 61 | 62 | // CommentConfig stores configuration necessary to publish comparison results as comments. 63 | type CommentConfig struct { 64 | Provider CommentProvider 65 | GitLab GitLabCommentConfig 66 | } 67 | 68 | // GitLabCommentConfig supplies the details required to comment on a GitLab Merge Request. 69 | type GitLabCommentConfig struct { 70 | BaseURL string 71 | Token string 72 | ProjectID string 73 | MergeRequestIID int 74 | } 75 | 76 | func (c CommentConfig) validate() error { 77 | switch c.Provider { 78 | case CommentProviderNone: 79 | return nil 80 | case CommentProviderGitLab: 81 | if c.GitLab.BaseURL == "" || c.GitLab.Token == "" || c.GitLab.ProjectID == "" || c.GitLab.MergeRequestIID == 0 { 82 | return fmt.Errorf("gitlab comment configuration requires base URL, token, project ID, and merge request IID") 83 | } 84 | return nil 85 | default: 86 | return fmt.Errorf("unsupported comment provider %q", c.Provider) 87 | } 88 | } 89 | 90 | // WithCommentConfig enables or updates comment publishing settings. 91 | func WithCommentConfig(commentCfg CommentConfig) ConfigOption { 92 | return func(cfg *Config) { 93 | cfg.Comment = &CommentConfig{ 94 | Provider: commentCfg.Provider, 95 | GitLab: commentCfg.GitLab, 96 | } 97 | } 98 | } 99 | 100 | // WithFileToCompare sets the specific manifest file to inspect. 101 | func WithFileToCompare(file string) ConfigOption { 102 | return func(cfg *Config) { 103 | cfg.FileToCompare = file 104 | } 105 | } 106 | 107 | // WithFilesToIgnore configures manifest paths that should be skipped. 108 | func WithFilesToIgnore(files []string) ConfigOption { 109 | return func(cfg *Config) { 110 | cfg.FilesToIgnore = append([]string{}, files...) 111 | } 112 | } 113 | 114 | // WithPreserveHelmLabels toggles stripping of Helm-managed labels. 115 | func WithPreserveHelmLabels(enabled bool) ConfigOption { 116 | return func(cfg *Config) { 117 | cfg.PreserveHelmLabels = enabled 118 | } 119 | } 120 | 121 | // WithPrintAdded determines whether newly added manifests are rendered. 122 | func WithPrintAdded(enabled bool) ConfigOption { 123 | return func(cfg *Config) { 124 | cfg.PrintAddedManifests = enabled 125 | } 126 | } 127 | 128 | // WithPrintRemoved determines whether removed manifests are rendered. 129 | func WithPrintRemoved(enabled bool) ConfigOption { 130 | return func(cfg *Config) { 131 | cfg.PrintRemovedManifests = enabled 132 | } 133 | } 134 | 135 | // WithCacheDir overrides the cache directory used for Helm charts. 136 | func WithCacheDir(path string) ConfigOption { 137 | return func(cfg *Config) { 138 | cfg.CacheDir = path 139 | } 140 | } 141 | 142 | // WithTempDirBase overrides the base directory for temporary workspaces. 143 | func WithTempDirBase(path string) ConfigOption { 144 | return func(cfg *Config) { 145 | if path != "" { 146 | cfg.TempDirBase = path 147 | } 148 | } 149 | } 150 | 151 | // WithExternalDiffTool specifies an external diff viewer to launch. 152 | func WithExternalDiffTool(tool string) ConfigOption { 153 | return func(cfg *Config) { 154 | cfg.ExternalDiffTool = tool 155 | } 156 | } 157 | 158 | // WithDebug toggles verbose logging. 159 | func WithDebug(enabled bool) ConfigOption { 160 | return func(cfg *Config) { 161 | cfg.Debug = enabled 162 | } 163 | } 164 | 165 | // WithVersion sets the application version used in log output. 166 | func WithVersion(version string) ConfigOption { 167 | return func(cfg *Config) { 168 | cfg.Version = version 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /internal/helpers/helpers_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/shini4i/argo-compare/internal/models" 9 | "github.com/spf13/afero" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | const ( 15 | expectedStrippedOutput = `# for testing purpose we need only limited fields 16 | apiVersion: apps/v1 17 | kind: Deployment 18 | metadata: 19 | labels: 20 | app.kubernetes.io/instance: traefik-web 21 | app.kubernetes.io/name: traefik 22 | argocd.argoproj.io/instance: traefik 23 | name: traefik 24 | namespace: web 25 | ` 26 | helmDeploymentWithManagedLabels = `# for testing purpose we need only limited fields 27 | apiVersion: apps/v1 28 | kind: Deployment 29 | metadata: 30 | labels: 31 | app.kubernetes.io/instance: traefik-web 32 | app.kubernetes.io/managed-by: Helm 33 | app.kubernetes.io/name: traefik 34 | argocd.argoproj.io/instance: traefik 35 | helm.sh/chart: traefik-23.0.1 36 | name: traefik 37 | namespace: web 38 | ` 39 | ) 40 | 41 | func TestGetEnv(t *testing.T) { 42 | // Test case 1: Check if an existing environment variable is retrieved 43 | expectedValue := "test value" 44 | t.Setenv("TEST_KEY", expectedValue) 45 | 46 | actualValue := GetEnv("TEST_KEY", "fallback") 47 | assert.Equal(t, expectedValue, actualValue) 48 | 49 | // Test case 2: Check if a missing environment variable falls back to the default value 50 | expectedValue = "fallback" 51 | actualValue = GetEnv("MISSING_KEY", expectedValue) 52 | assert.Equal(t, expectedValue, actualValue) 53 | } 54 | 55 | func TestStripHelmLabels(t *testing.T) { 56 | tmpDir := t.TempDir() 57 | sourcePath := filepath.Join(tmpDir, "deployment.yaml") 58 | require.NoError(t, os.WriteFile(sourcePath, []byte(helmDeploymentWithManagedLabels), 0o644)) 59 | 60 | fileContent, err := StripHelmLabels(sourcePath) 61 | 62 | assert.NoError(t, err) 63 | assert.Equal(t, expectedStrippedOutput, string(fileContent)) 64 | 65 | // We want to be sure that the function returns an error if the file cannot be read 66 | _, err = StripHelmLabels(filepath.Join(tmpDir, "missing.yaml")) 67 | assert.Error(t, err) 68 | } 69 | 70 | func TestWriteToFile(t *testing.T) { 71 | fs := afero.NewMemMapFs() 72 | 73 | // Test case 1: Check the successful case 74 | filePath := "output.txt" 75 | 76 | // Call the function to write data to file 77 | err := WriteToFile(fs, filePath, []byte(expectedStrippedOutput)) 78 | assert.NoError(t, err) 79 | 80 | // Read the written file 81 | writtenData, err := afero.ReadFile(fs, filePath) 82 | assert.NoError(t, err) 83 | 84 | // Compare the written data with the test data 85 | assert.Equal(t, expectedStrippedOutput, string(writtenData)) 86 | 87 | // Cleanup: Remove the written file 88 | err = fs.Remove(filePath) 89 | assert.NoError(t, err) 90 | 91 | // Test case 2: Check the error case (we should get an error if the file cannot be written) 92 | fs = afero.NewReadOnlyFs(fs) 93 | 94 | filePath = "invalid/output.txt" 95 | err = WriteToFile(fs, filePath, []byte(expectedStrippedOutput)) 96 | assert.Error(t, err) 97 | } 98 | 99 | func TestCreateTempFile(t *testing.T) { 100 | t.Run("create and write successful", func(t *testing.T) { 101 | // Create a new in-memory filesystem 102 | fs := afero.NewMemMapFs() 103 | 104 | // Run the function to test 105 | file, err := CreateTempFile(fs, "test content") 106 | assert.NoError(t, err) 107 | 108 | // Check that the file contains the expected content 109 | content, err := afero.ReadFile(fs, file.Name()) 110 | assert.NoError(t, err) 111 | assert.Equal(t, "test content", string(content)) 112 | }) 113 | 114 | t.Run("failed to create file", func(t *testing.T) { 115 | // Create a read-only in-memory filesystem 116 | fs := afero.NewReadOnlyFs(afero.NewMemMapFs()) 117 | 118 | // Run the function to test 119 | _, err := CreateTempFile(fs, "test content") 120 | 121 | // assert error to contain the expected message 122 | assert.Contains(t, err.Error(), "failed to create temporary file") 123 | }) 124 | } 125 | 126 | func TestFindHelmRepoCredentials(t *testing.T) { 127 | repoCreds := []models.RepoCredentials{ 128 | { 129 | Url: "https://charts.example.com", 130 | Username: "user", 131 | Password: "pass", 132 | }, 133 | { 134 | Url: "https://charts.test.com", 135 | Username: "testuser", 136 | Password: "testpass", 137 | }, 138 | } 139 | 140 | tests := []struct { 141 | name string 142 | url string 143 | expectedUser string 144 | expectedPass string 145 | }{ 146 | { 147 | name: "Credentials Found", 148 | url: "https://charts.example.com", 149 | expectedUser: "user", 150 | expectedPass: "pass", 151 | }, 152 | { 153 | name: "Credentials Not Found", 154 | url: "https://charts.notfound.com", 155 | expectedUser: "", 156 | expectedPass: "", 157 | }, 158 | } 159 | 160 | for _, tt := range tests { 161 | t.Run(tt.name, func(t *testing.T) { 162 | username, password := FindHelmRepoCredentials(tt.url, repoCreds) 163 | assert.Equal(t, tt.expectedUser, username) 164 | assert.Equal(t, tt.expectedPass, password) 165 | }) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /internal/sanitizer/secret_masker.go: -------------------------------------------------------------------------------- 1 | package sanitizer 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/shini4i/argo-compare/internal/ports" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | const ( 18 | maskPrefix = "ENC[sha256:" 19 | maskSuffix = "]" 20 | hashPrefixBytes = 16 21 | ) 22 | 23 | // KubernetesSecretMasker redacts sensitive values contained within Kubernetes Secret manifests. 24 | type KubernetesSecretMasker struct { 25 | mu sync.RWMutex 26 | hashCache map[string]string // keyed by the full SHA-256 digest to avoid retaining plaintext secrets. 27 | } 28 | 29 | // Ensure compile-time conformance to the SensitiveDataMasker contract. 30 | var _ ports.SensitiveDataMasker = (*KubernetesSecretMasker)(nil) 31 | 32 | // NewKubernetesSecretMasker constructs a masker capable of redacting Kubernetes Secret data values. 33 | func NewKubernetesSecretMasker() *KubernetesSecretMasker { 34 | return &KubernetesSecretMasker{ 35 | hashCache: make(map[string]string), 36 | } 37 | } 38 | 39 | // Mask redacts data and stringData values of Kubernetes Secret manifests while preserving other resources untouched. 40 | // It returns the potentially modified manifest bytes alongside a flag indicating whether masking occurred. 41 | func (m *KubernetesSecretMasker) Mask(content []byte) ([]byte, bool, error) { 42 | if len(content) == 0 { 43 | return content, false, nil 44 | } 45 | 46 | decoder := yaml.NewDecoder(bytes.NewReader(content)) 47 | var documents []*yaml.Node 48 | var masked bool 49 | 50 | for { 51 | var document yaml.Node 52 | if err := decoder.Decode(&document); err != nil { 53 | if errors.Is(err, io.EOF) { 54 | break 55 | } 56 | return nil, false, fmt.Errorf("decode manifest: %w", err) 57 | } 58 | 59 | if sanitizeSecretDocument(&document, m.buildMaskedValue) { 60 | masked = true 61 | } 62 | 63 | documents = append(documents, &document) 64 | } 65 | 66 | if !masked { 67 | return content, false, nil 68 | } 69 | 70 | var buffer bytes.Buffer 71 | encoder := yaml.NewEncoder(&buffer) 72 | encoder.SetIndent(2) 73 | 74 | for _, document := range documents { 75 | if err := encoder.Encode(document); err != nil { 76 | return nil, false, fmt.Errorf("encode manifest: %w", err) 77 | } 78 | } 79 | 80 | if err := encoder.Close(); err != nil { 81 | return nil, false, fmt.Errorf("close encoder: %w", err) 82 | } 83 | 84 | return buffer.Bytes(), true, nil 85 | } 86 | 87 | // sanitizeSecretDocument traverses a YAML document and redacts Kubernetes Secret values in-place using the supplied masker. 88 | func sanitizeSecretDocument(document *yaml.Node, maskValue func(string) string) bool { 89 | if document == nil || document.Kind != yaml.DocumentNode || len(document.Content) == 0 { 90 | return false 91 | } 92 | 93 | root := document.Content[0] 94 | if root == nil || root.Kind != yaml.MappingNode { 95 | return false 96 | } 97 | 98 | kindNode := findMappingValue(root, "kind") 99 | if kindNode == nil || !strings.EqualFold(kindNode.Value, "Secret") { 100 | return false 101 | } 102 | 103 | maskedData := maskSecretMap(root, "data", maskValue) 104 | maskedStringData := maskSecretMap(root, "stringData", maskValue) 105 | 106 | return maskedData || maskedStringData 107 | } 108 | 109 | // maskSecretMap locates the provided key on the mapping node and redacts all scalar values within it using the provided masking function. 110 | func maskSecretMap(parent *yaml.Node, key string, maskValue func(string) string) bool { 111 | keyIndex := findMappingKeyIndex(parent, key) 112 | if keyIndex < 0 { 113 | return false 114 | } 115 | 116 | valueNode := parent.Content[keyIndex+1] 117 | if valueNode == nil || valueNode.Kind != yaml.MappingNode { 118 | return false 119 | } 120 | 121 | var masked bool 122 | for i := 0; i < len(valueNode.Content); i += 2 { 123 | if i+1 >= len(valueNode.Content) { 124 | continue 125 | } 126 | value := valueNode.Content[i+1] 127 | if value == nil || value.Kind != yaml.ScalarNode { 128 | continue 129 | } 130 | 131 | value.Value = maskValue(value.Value) 132 | value.Tag = "!!str" 133 | value.Style = yaml.Style(0) // yaml.Style(0) keeps the scalar in plain style; yaml.v3 does not expose a named constant. 134 | masked = true 135 | } 136 | 137 | return masked 138 | } 139 | 140 | // findMappingValue retrieves the value node for the supplied key within a mapping node. 141 | func findMappingValue(mapping *yaml.Node, key string) *yaml.Node { 142 | index := findMappingKeyIndex(mapping, key) 143 | if index < 0 || index+1 >= len(mapping.Content) { 144 | return nil 145 | } 146 | return mapping.Content[index+1] 147 | } 148 | 149 | // findMappingKeyIndex returns the index of the key node within the mapping node content slice. 150 | func findMappingKeyIndex(mapping *yaml.Node, key string) int { 151 | if mapping == nil || mapping.Kind != yaml.MappingNode { 152 | return -1 153 | } 154 | 155 | for i := 0; i < len(mapping.Content); i += 2 { 156 | currentKey := mapping.Content[i] 157 | if currentKey == nil || currentKey.Kind != yaml.ScalarNode { 158 | continue 159 | } 160 | if strings.EqualFold(currentKey.Value, key) { 161 | return i 162 | } 163 | } 164 | 165 | return -1 166 | } 167 | 168 | // buildMaskedValue returns a deterministic redacted placeholder for the provided secret value while reusing cached computations. 169 | func (m *KubernetesSecretMasker) buildMaskedValue(value string) string { 170 | digest := sha256.Sum256([]byte(value)) 171 | digestKey := hex.EncodeToString(digest[:]) 172 | 173 | m.mu.RLock() 174 | masked, ok := m.hashCache[digestKey] 175 | m.mu.RUnlock() 176 | if ok { 177 | return masked 178 | } 179 | 180 | prefix := hex.EncodeToString(digest[:hashPrefixBytes]) 181 | masked = maskPrefix + prefix + maskSuffix 182 | 183 | m.mu.Lock() 184 | if cached, exists := m.hashCache[digestKey]; exists { 185 | m.mu.Unlock() 186 | return cached 187 | } 188 | m.hashCache[digestKey] = masked 189 | m.mu.Unlock() 190 | 191 | return masked 192 | } 193 | -------------------------------------------------------------------------------- /cmd/argo-compare/command/root_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/shini4i/argo-compare/internal/app" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestExecuteRunsAppWithFlags(t *testing.T) { 15 | var receivedConfig app.Config 16 | 17 | opts := Options{ 18 | Version: "test-version", 19 | CacheDir: t.TempDir(), 20 | TempDirBase: os.TempDir(), 21 | ExternalDiffTool: "diff-tool", 22 | InitLogging: func(bool) {}, 23 | RunApp: func(cfg app.Config) error { 24 | receivedConfig = cfg 25 | return nil 26 | }, 27 | } 28 | 29 | args := []string{ 30 | "branch", "main", 31 | "--file", "app.yaml", 32 | "--ignore", "foo.yaml", 33 | "--preserve-helm-labels", 34 | "--print-added-manifests", 35 | "--print-removed-manifests", 36 | } 37 | 38 | err := Execute(opts, args) 39 | require.NoError(t, err) 40 | 41 | assert.Equal(t, "main", receivedConfig.TargetBranch) 42 | assert.Equal(t, "app.yaml", receivedConfig.FileToCompare) 43 | assert.Equal(t, []string{"foo.yaml"}, receivedConfig.FilesToIgnore) 44 | assert.True(t, receivedConfig.PreserveHelmLabels) 45 | assert.True(t, receivedConfig.PrintAddedManifests) 46 | assert.True(t, receivedConfig.PrintRemovedManifests) 47 | assert.Equal(t, "diff-tool", receivedConfig.ExternalDiffTool) 48 | assert.Equal(t, "test-version", receivedConfig.Version) 49 | } 50 | 51 | func TestExecuteHonoursFullOutputFlag(t *testing.T) { 52 | var receivedConfig app.Config 53 | 54 | opts := Options{ 55 | Version: "test-version", 56 | CacheDir: t.TempDir(), 57 | TempDirBase: os.TempDir(), 58 | ExternalDiffTool: "", 59 | InitLogging: func(bool) {}, 60 | RunApp: func(cfg app.Config) error { 61 | receivedConfig = cfg 62 | return nil 63 | }, 64 | } 65 | 66 | err := Execute(opts, []string{"branch", "main", "--full-output"}) 67 | require.NoError(t, err) 68 | 69 | assert.True(t, receivedConfig.PrintAddedManifests) 70 | assert.True(t, receivedConfig.PrintRemovedManifests) 71 | } 72 | 73 | func TestExecuteDropCache(t *testing.T) { 74 | tempDir := t.TempDir() 75 | cacheDir := filepath.Join(tempDir, "cache") 76 | require.NoError(t, os.MkdirAll(cacheDir, 0o755)) 77 | file := filepath.Join(cacheDir, "test.txt") 78 | require.NoError(t, os.WriteFile(file, []byte("data"), 0o644)) 79 | 80 | called := false 81 | 82 | opts := Options{ 83 | Version: "test-version", 84 | CacheDir: cacheDir, 85 | TempDirBase: os.TempDir(), 86 | ExternalDiffTool: "", 87 | InitLogging: func(bool) {}, 88 | RunApp: func(app.Config) error { 89 | called = true 90 | return nil 91 | }, 92 | } 93 | 94 | err := Execute(opts, []string{"--drop-cache"}) 95 | require.NoError(t, err) 96 | 97 | _, statErr := os.Stat(cacheDir) 98 | assert.True(t, os.IsNotExist(statErr)) 99 | assert.False(t, called, "run function should not execute when dropping cache") 100 | } 101 | 102 | func TestExecuteErrorScenarios(t *testing.T) { 103 | cases := []struct { 104 | name string 105 | setupOpts func(t *testing.T) Options 106 | args []string 107 | wantErr string 108 | }{ 109 | { 110 | name: "missing run handler", 111 | setupOpts: func(t *testing.T) Options { 112 | return Options{ 113 | Version: "test", 114 | CacheDir: t.TempDir(), 115 | TempDirBase: t.TempDir(), 116 | ExternalDiffTool: "", 117 | InitLogging: func(bool) {}, 118 | RunApp: nil, 119 | } 120 | }, 121 | args: []string{"branch", "main"}, 122 | wantErr: "no run handler provided", 123 | }, 124 | { 125 | name: "run handler failure", 126 | setupOpts: func(t *testing.T) Options { 127 | return Options{ 128 | Version: "test", 129 | CacheDir: t.TempDir(), 130 | TempDirBase: t.TempDir(), 131 | ExternalDiffTool: "", 132 | InitLogging: func(bool) {}, 133 | RunApp: func(app.Config) error { 134 | return errors.New("execution failed") 135 | }, 136 | } 137 | }, 138 | args: []string{"branch", "main"}, 139 | wantErr: "execution failed", 140 | }, 141 | { 142 | name: "missing branch argument", 143 | setupOpts: func(t *testing.T) Options { 144 | return Options{ 145 | Version: "test", 146 | CacheDir: t.TempDir(), 147 | TempDirBase: t.TempDir(), 148 | ExternalDiffTool: "", 149 | InitLogging: func(bool) {}, 150 | RunApp: func(app.Config) error { 151 | t.Fatalf("RunApp should not be called") 152 | return nil 153 | }, 154 | } 155 | }, 156 | args: []string{"branch"}, 157 | wantErr: "accepts 1 arg(s), received 0", 158 | }, 159 | } 160 | 161 | for _, tc := range cases { 162 | tc := tc 163 | t.Run(tc.name, func(t *testing.T) { 164 | opts := tc.setupOpts(t) 165 | err := Execute(opts, tc.args) 166 | require.Error(t, err) 167 | assert.Contains(t, err.Error(), tc.wantErr) 168 | }) 169 | } 170 | } 171 | 172 | func TestExecuteUsesGitLabCIEnvDefaults(t *testing.T) { 173 | var receivedConfig app.Config 174 | 175 | opts := Options{ 176 | Version: "test-version", 177 | CacheDir: t.TempDir(), 178 | TempDirBase: os.TempDir(), 179 | ExternalDiffTool: "", 180 | InitLogging: func(bool) {}, 181 | RunApp: func(cfg app.Config) error { 182 | receivedConfig = cfg 183 | return nil 184 | }, 185 | } 186 | 187 | t.Setenv("GITLAB_CI", "true") 188 | t.Setenv("CI_MERGE_REQUEST_IID", "42") 189 | t.Setenv("CI_PROJECT_ID", "321") 190 | t.Setenv("CI_SERVER_URL", "https://gitlab.example.com") 191 | t.Setenv("CI_JOB_TOKEN", "job-token") 192 | 193 | err := Execute(opts, []string{"branch", "main"}) 194 | require.NoError(t, err) 195 | 196 | require.NotNil(t, receivedConfig.Comment) 197 | assert.Equal(t, app.CommentProviderGitLab, receivedConfig.Comment.Provider) 198 | assert.Equal(t, "https://gitlab.example.com", receivedConfig.Comment.GitLab.BaseURL) 199 | assert.Equal(t, "321", receivedConfig.Comment.GitLab.ProjectID) 200 | assert.Equal(t, 42, receivedConfig.Comment.GitLab.MergeRequestIID) 201 | assert.Equal(t, "job-token", receivedConfig.Comment.GitLab.Token) 202 | } 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Argo Compare 4 | 5 | A comparison tool for displaying the differences between applications in different Git branches 6 | 7 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/shini4i/argo-compare/run-tests.yml?branch=main) 8 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/shini4i/argo-compare) 9 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/shini4i/argo-compare) 10 | [![codecov](https://codecov.io/gh/shini4i/argo-compare/branch/main/graph/badge.svg?token=48E1OZHLPY)](https://codecov.io/gh/shini4i/argo-compare) 11 | [![Go Report Card](https://goreportcard.com/badge/github.com/shini4i/argo-compare)](https://goreportcard.com/report/github.com/shini4i/argo-compare) 12 | ![GitHub](https://img.shields.io/github/license/shini4i/argo-compare) 13 | 14 | Showcase 15 | 16 | Example output of `argo-compare` with `diff-so-fancy` 17 |
18 | 19 | ## General information 20 | 21 | This tool will show what would be changed in the manifests rendered by helm after changes to the specific Application 22 | are merged into the target branch. 23 | 24 | ### How to install 25 | 26 | The binary can be installed using homebrew: 27 | 28 | ```bash 29 | brew install shini4i/tap/argo-compare 30 | ``` 31 | 32 | ### How to use 33 | 34 | The simplest usage scenario is to compare all changed files in the current branch with the target branch: 35 | 36 | ```bash 37 | argo-compare branch 38 | ``` 39 | 40 | If you want to compare only specific file, you can use the `--file` flag: 41 | 42 | ```bash 43 | argo-compare branch --file 44 | ``` 45 | 46 | By default, argo-compare will print only changed files content, but if this behavior is not desired, you can use one of the following flags: 47 | ```bash 48 | # In addition to the changed files, it will print all added manifests 49 | argo-compare branch --print-added-manifests 50 | # In addition to the changed files, it will print all removed manifests 51 | argo-compare branch --print-removed-manifests 52 | # Print all changed, added and removed manifests 53 | argo-compare branch --full-output 54 | ``` 55 | 56 | To use an external diff tool, you can set `EXTERNAL_DIFF_TOOL` environment variable. Each file diff will be passed in a pipe to the external tool. 57 | ```bash 58 | EXTERNAL_DIFF_TOOL=diff-so-fancy argo-compare branch 59 | ``` 60 | 61 | Additionally, you can try this tool using docker container: 62 | ```bash 63 | docker run -it --mount type=bind,source="$(pwd)",target=/apps --env EXTERNAL_DIFF_TOOL=diff-so-fancy --workdir /apps ghcr.io/shini4i/argo-compare: branch --full-output 64 | ``` 65 | 66 | To post the comparison as a comment to a GitLab Merge Request, provide the GitLab provider and credentials either with flags or environment variables: 67 | 68 | ```bash 69 | ARGO_COMPARE_COMMENT_PROVIDER=gitlab \ 70 | ARGO_COMPARE_GITLAB_URL=https://gitlab.com \ 71 | ARGO_COMPARE_GITLAB_TOKEN=$GITLAB_TOKEN \ 72 | ARGO_COMPARE_GITLAB_PROJECT_ID=12345 \ 73 | ARGO_COMPARE_GITLAB_MR_IID=10 \ 74 | argo-compare branch 75 | ``` 76 | 77 | Equivalent CLI flags are available: 78 | 79 | ```bash 80 | argo-compare branch \ 81 | --comment-provider gitlab \ 82 | --gitlab-url https://gitlab.com \ 83 | --gitlab-token "$GITLAB_TOKEN" \ 84 | --gitlab-project-id 12345 \ 85 | --gitlab-merge-request-iid 10 86 | ``` 87 | 88 | When running inside GitLab CI, most settings are detected automatically: 89 | 90 | - `--comment-provider` defaults to `gitlab` when `GITLAB_CI` and `CI_MERGE_REQUEST_IID` are present. 91 | - `--gitlab-url` falls back to `CI_SERVER_URL`. 92 | - `--gitlab-project-id` falls back to `CI_PROJECT_ID`. 93 | - `--gitlab-merge-request-iid` falls back to `CI_MERGE_REQUEST_IID`. 94 | - `--gitlab-token` falls back to `CI_JOB_TOKEN` if no explicit token is provided (ensure the token has the necessary scope to post notes). 95 | 96 | ### Sensitive data handling 97 | 98 | `argo-compare` masks the rendered contents of Kubernetes `Secret` manifests before they reach stdout logs, external diff tools, or merge request comments. Each secret entry is replaced with a deterministic hash placeholder, allowing reviewers to spot that a value changed without exposing the underlying secret material. 99 | 100 | #### Password Protected Repositories 101 | Using password protected repositories is a bit more challenging. To make it work, we need to expose JSON as an environment variable. 102 | The JSON should contain the following fields: 103 | 104 | ```json 105 | { 106 | "url": "https://charts.example.com", 107 | "username": "username", 108 | "password": "password" 109 | } 110 | ``` 111 | How to properly expose it depends on the specific use case. 112 | 113 | A bash example: 114 | ```bash 115 | export REPO_CREDS_EXAMPLE={\"url\":\"https://charts.example.com\",\"username\":\"username\",\"password\":\"password\"} 116 | ``` 117 | 118 | Where `EXAMPLE` is an identifier that is not used by the application. 119 | 120 | Argo Compare will look for all `REPO_CREDS_*` environment variables and use them if `url` will match the `repoURL` from Application manifest. 121 | 122 | 123 | ## How it works 124 | 125 | 1) First, this tool will check which files are changed compared to the files in the target branch. 126 | 2) It will get the content of the changed Application files from the target branch. 127 | 3) It will render manifests using the helm template using source and target branch values. 128 | 4) It will get rid of helm related labels as they are not important for the comparison. (It can be skipped by providing `--preserve-helm-labels` flag) 129 | 5) As the last step, it will compare rendered manifest from the source and destination branches and print the 130 | difference. 131 | 132 | ## Current limitations 133 | 134 | - Works only with Applications that are using helm repositories and helm values present in the Application yaml. 135 | - Does not support password protected repositories. 136 | 137 | ## Roadmap 138 | 139 | - [ ] Add support for Application using git as a source of helm chart 140 | - [x] Add support for providing credentials for password protected helm repositories 141 | - [x] Add support for posting diff as a comment to MR (GitLab) 142 | - [ ] Add support for posting diff as a comment to PR (GitHub) 143 | 144 | ## Contributing 145 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 146 | -------------------------------------------------------------------------------- /internal/app/git_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/go-git/go-git/v5" 10 | "github.com/go-git/go-git/v5/config" 11 | "github.com/go-git/go-git/v5/plumbing" 12 | "github.com/op/go-logging" 13 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 14 | "github.com/spf13/afero" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestGitRepoGetChangedFilesRespectsIgnore(t *testing.T) { 19 | tempDir := t.TempDir() 20 | workDir := filepath.Join(tempDir, "repo") 21 | require.NoError(t, os.MkdirAll(workDir, 0o755)) 22 | 23 | repo, err := git.PlainInit(workDir, false) 24 | require.NoError(t, err) 25 | 26 | err = repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))) 27 | require.NoError(t, err) 28 | 29 | writeApplication(t, workDir, `1.0.0`, 1) 30 | writeExtraApplication(t, workDir, "secondary", `1.0.0`, 1) 31 | 32 | worktree, err := repo.Worktree() 33 | require.NoError(t, err) 34 | 35 | _, err = worktree.Add("apps/demo.yaml") 36 | require.NoError(t, err) 37 | _, err = worktree.Add("apps/secondary.yaml") 38 | require.NoError(t, err) 39 | 40 | commitHash, err := worktree.Commit("initial", &git.CommitOptions{Author: defaultSignature()}) 41 | require.NoError(t, err) 42 | 43 | remotePath := filepath.Join(tempDir, "origin.git") 44 | _, err = git.PlainInit(remotePath, true) 45 | require.NoError(t, err) 46 | 47 | _, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}}) 48 | require.NoError(t, err) 49 | 50 | err = repo.Push(&git.PushOptions{RemoteName: "origin", RefSpecs: []config.RefSpec{"refs/heads/main:refs/heads/main"}}) 51 | require.NoError(t, err) 52 | 53 | err = repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName("refs/remotes/origin/main"), commitHash)) 54 | require.NoError(t, err) 55 | 56 | err = worktree.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName("feature"), Create: true}) 57 | require.NoError(t, err) 58 | 59 | writeApplication(t, workDir, `1.1.0`, 2) 60 | writeExtraApplication(t, workDir, "secondary", `2.0.0`, 3) 61 | 62 | _, err = worktree.Add("apps/demo.yaml") 63 | require.NoError(t, err) 64 | _, err = worktree.Add("apps/secondary.yaml") 65 | require.NoError(t, err) 66 | 67 | _, err = worktree.Commit("update", &git.CommitOptions{Author: defaultSignature()}) 68 | require.NoError(t, err) 69 | 70 | originalWD, err := os.Getwd() 71 | require.NoError(t, err) 72 | require.NoError(t, os.Chdir(workDir)) 73 | t.Cleanup(func() { 74 | require.NoError(t, os.Chdir(originalWD)) 75 | }) 76 | 77 | logger := logging.MustGetLogger("git-test") 78 | repoInstance, err := NewGitRepo(afero.NewOsFs(), noopCmdRunner{}, utils.OsFileReader{}, logger) 79 | require.NoError(t, err) 80 | 81 | result, err := repoInstance.GetChangedFiles("main", []string{"apps/secondary.yaml"}) 82 | require.NoError(t, err) 83 | 84 | require.ElementsMatch(t, []string{"apps/demo.yaml"}, result.Applications) 85 | require.Empty(t, result.Invalid) 86 | } 87 | 88 | func TestGitRepoTreeForBranchReturnsTree(t *testing.T) { 89 | repoInstance, _ := buildGitRepo(t, true) 90 | 91 | tree, err := repoInstance.treeForBranch("main") 92 | require.NoError(t, err) 93 | require.NotNil(t, tree) 94 | } 95 | 96 | func TestGitRepoTreeForBranchMissingRemote(t *testing.T) { 97 | repoInstance, _ := buildGitRepo(t, false) 98 | 99 | _, err := repoInstance.treeForBranch("main") 100 | require.Error(t, err) 101 | } 102 | 103 | func TestGitRepoTargetFileContent(t *testing.T) { 104 | repoInstance, _ := buildGitRepo(t, true) 105 | 106 | tree, err := repoInstance.treeForBranch("main") 107 | require.NoError(t, err) 108 | 109 | content, err := repoInstance.targetFileContent(tree, "main", "apps/demo.yaml", false) 110 | require.NoError(t, err) 111 | require.Contains(t, content, "replicaCount") 112 | 113 | _, err = repoInstance.targetFileContent(tree, "main", "apps/missing.yaml", false) 114 | require.ErrorIs(t, err, gitFileDoesNotExist) 115 | 116 | content, err = repoInstance.targetFileContent(tree, "main", "apps/missing.yaml", true) 117 | require.NoError(t, err) 118 | require.Empty(t, content) 119 | } 120 | 121 | func TestGitRepoParseTargetApplication(t *testing.T) { 122 | repoInstance, _ := buildGitRepo(t, true) 123 | 124 | const appYAML = `apiVersion: argoproj.io/v1alpha1 125 | kind: Application 126 | metadata: 127 | name: parsed 128 | namespace: argocd 129 | spec: 130 | destination: 131 | server: https://kubernetes.default.svc 132 | namespace: demo 133 | source: 134 | repoURL: fake.repo/charts 135 | chart: parsed-chart 136 | targetRevision: 1.0.0 137 | helm: 138 | releaseName: parsed 139 | ` 140 | 141 | application, err := repoInstance.parseTargetApplication(appYAML) 142 | require.NoError(t, err) 143 | require.Equal(t, "parsed", application.Metadata.Name) 144 | require.Equal(t, "parsed-chart", application.Spec.Source.Chart) 145 | } 146 | 147 | func writeExtraApplication(t *testing.T, repoDir, name, version string, replicas int) { 148 | t.Helper() 149 | content := []byte(`apiVersion: argoproj.io/v1alpha1 150 | kind: Application 151 | metadata: 152 | name: ` + name + ` 153 | namespace: argocd 154 | spec: 155 | destination: 156 | server: https://kubernetes.default.svc 157 | namespace: demo 158 | source: 159 | repoURL: fake.repo/charts 160 | chart: ` + name + `-chart 161 | targetRevision: ` + version + ` 162 | helm: 163 | releaseName: ` + name + ` 164 | values: | 165 | replicaCount: ` + fmt.Sprintf("%d", replicas) + ` 166 | `) 167 | 168 | appPath := filepath.Join(repoDir, "apps") 169 | require.NoError(t, os.MkdirAll(appPath, 0o755)) 170 | require.NoError(t, os.WriteFile(filepath.Join(appPath, name+".yaml"), content, 0o644)) 171 | } 172 | 173 | func buildGitRepo(t *testing.T, includeRemote bool) (*GitRepo, *git.Repository) { 174 | t.Helper() 175 | 176 | tempDir := t.TempDir() 177 | workDir := filepath.Join(tempDir, "repo") 178 | require.NoError(t, os.MkdirAll(workDir, 0o755)) 179 | 180 | repo, err := git.PlainInit(workDir, false) 181 | require.NoError(t, err) 182 | 183 | err = repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))) 184 | require.NoError(t, err) 185 | 186 | writeApplication(t, workDir, `1.0.0`, 1) 187 | 188 | worktree, err := repo.Worktree() 189 | require.NoError(t, err) 190 | 191 | _, err = worktree.Add("apps/demo.yaml") 192 | require.NoError(t, err) 193 | 194 | commitHash, err := worktree.Commit("initial", &git.CommitOptions{Author: defaultSignature()}) 195 | require.NoError(t, err) 196 | 197 | if includeRemote { 198 | err = repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName("refs/remotes/origin/main"), commitHash)) 199 | require.NoError(t, err) 200 | } 201 | 202 | originalWD, err := os.Getwd() 203 | require.NoError(t, err) 204 | 205 | err = os.Chdir(workDir) 206 | require.NoError(t, err) 207 | 208 | t.Cleanup(func() { 209 | require.NoError(t, os.Chdir(originalWD)) 210 | }) 211 | 212 | logger := logging.MustGetLogger(fmt.Sprintf("git-test-%s", t.Name())) 213 | repoInstance, err := NewGitRepo(afero.NewOsFs(), noopCmdRunner{}, utils.OsFileReader{}, logger) 214 | require.NoError(t, err) 215 | 216 | return repoInstance, repo 217 | } 218 | -------------------------------------------------------------------------------- /internal/app/compare.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/codingsince1985/checksum" 11 | "github.com/hexops/gotextdiff" 12 | "github.com/hexops/gotextdiff/myers" 13 | "github.com/hexops/gotextdiff/span" 14 | "github.com/shini4i/argo-compare/internal/helpers" 15 | "github.com/shini4i/argo-compare/internal/ports" 16 | "github.com/spf13/afero" 17 | ) 18 | 19 | // File captures the relative name and checksum of a rendered manifest. 20 | type File struct { 21 | Name string 22 | Sha string 23 | } 24 | 25 | // DiffOutput contains the unified diff for a single manifest. 26 | type DiffOutput struct { 27 | File File 28 | Diff string 29 | } 30 | 31 | // ComparisonResult aggregates the additions, removals, and changes discovered. 32 | type ComparisonResult struct { 33 | Added []DiffOutput 34 | Removed []DiffOutput 35 | Changed []DiffOutput 36 | } 37 | 38 | // IsEmpty reports whether there are no changes to present. 39 | func (r ComparisonResult) IsEmpty() bool { 40 | return len(r.Added) == 0 && len(r.Removed) == 0 && len(r.Changed) == 0 41 | } 42 | 43 | // Compare analyses rendered manifest trees to produce diff results. 44 | const yamlGlob = "*.yaml" 45 | 46 | type Compare struct { 47 | Globber ports.Globber 48 | TmpDir string 49 | PreserveHelmLabels bool 50 | Masker ports.SensitiveDataMasker // Sanitizes manifest content prior to diffing. 51 | 52 | srcFiles []File 53 | dstFiles []File 54 | addedFiles []File 55 | removedFiles []File 56 | diffFiles []File 57 | } 58 | 59 | // Execute orchestrates the comparison of rendered manifests. 60 | func (c *Compare) Execute() (ComparisonResult, error) { 61 | if err := c.prepareFiles(); err != nil { 62 | return ComparisonResult{}, err 63 | } 64 | 65 | c.generateFilesStatus() 66 | 67 | return c.buildResult() 68 | } 69 | 70 | // prepareFiles normalizes render outputs and populates source/destination file lists. 71 | func (c *Compare) prepareFiles() error { 72 | if !c.PreserveHelmLabels { 73 | if err := c.stripHelmLabels(); err != nil { 74 | return err 75 | } 76 | } 77 | 78 | srcPattern := filepath.Join(c.TmpDir, "templates", "src", "**", yamlGlob) 79 | srcFiles, err := c.Globber.Glob(srcPattern) 80 | if err != nil { 81 | if !os.IsNotExist(err) { 82 | return err 83 | } 84 | srcFiles = nil 85 | } 86 | c.srcFiles, err = c.processFiles(srcFiles, "src") 87 | if err != nil { 88 | return err 89 | } 90 | 91 | dstPattern := filepath.Join(c.TmpDir, "templates", "dst", "**", yamlGlob) 92 | dstFiles, err := c.Globber.Glob(dstPattern) 93 | if err != nil { 94 | if !os.IsNotExist(err) { 95 | return err 96 | } 97 | dstFiles = nil 98 | } 99 | c.dstFiles, err = c.processFiles(dstFiles, "dst") 100 | if err != nil { 101 | return err 102 | } 103 | 104 | return nil 105 | } 106 | 107 | // processFiles records manifest metadata for the supplied file set. 108 | func (c *Compare) processFiles(files []string, filesType string) ([]File, error) { 109 | var processedFiles []File 110 | 111 | path := filepath.Join(c.TmpDir, "templates", filesType) 112 | 113 | for _, file := range files { 114 | sha256sum, err := checksum.SHA256sum(file) 115 | if err != nil { 116 | return nil, err 117 | } 118 | processedFiles = append(processedFiles, File{Name: strings.TrimPrefix(file, path), Sha: sha256sum}) 119 | } 120 | 121 | return processedFiles, nil 122 | } 123 | 124 | // generateFilesStatus computes the sets of added, removed, and changed manifests. 125 | func (c *Compare) generateFilesStatus() { 126 | srcFileMap := make(map[string]File) 127 | for _, srcFile := range c.srcFiles { 128 | srcFileMap[srcFile.Name] = srcFile 129 | } 130 | 131 | dstFileMap := make(map[string]File) 132 | for _, dstFile := range c.dstFiles { 133 | dstFileMap[dstFile.Name] = dstFile 134 | } 135 | 136 | for fileName, srcFile := range srcFileMap { 137 | if _, found := dstFileMap[fileName]; !found { 138 | c.addedFiles = append(c.addedFiles, srcFile) 139 | } 140 | } 141 | 142 | for fileName, dstFile := range dstFileMap { 143 | if srcFile, found := srcFileMap[fileName]; found { 144 | if srcFile.Sha != dstFile.Sha { 145 | c.diffFiles = append(c.diffFiles, srcFile) 146 | } 147 | } else { 148 | c.removedFiles = append(c.removedFiles, dstFile) 149 | } 150 | } 151 | } 152 | 153 | // buildResult produces the final comparison result with generated diffs. 154 | func (c *Compare) buildResult() (ComparisonResult, error) { 155 | added, err := c.generateDiffs(c.addedFiles) 156 | if err != nil { 157 | return ComparisonResult{}, err 158 | } 159 | 160 | removed, err := c.generateDiffs(c.removedFiles) 161 | if err != nil { 162 | return ComparisonResult{}, err 163 | } 164 | 165 | changed, err := c.generateDiffs(c.diffFiles) 166 | if err != nil { 167 | return ComparisonResult{}, err 168 | } 169 | 170 | return ComparisonResult{ 171 | Added: added, 172 | Removed: removed, 173 | Changed: changed, 174 | }, nil 175 | } 176 | 177 | // generateDiffs collects unified diff outputs for each provided file. 178 | func (c *Compare) generateDiffs(files []File) ([]DiffOutput, error) { 179 | var outputs []DiffOutput 180 | 181 | for _, f := range files { 182 | diff, err := c.generateDiff(f) 183 | if err != nil { 184 | return nil, err 185 | } 186 | outputs = append(outputs, DiffOutput{File: f, Diff: diff}) 187 | } 188 | 189 | return outputs, nil 190 | } 191 | 192 | // generateDiff creates the unified diff for a single manifest entry. 193 | func (c *Compare) generateDiff(f File) (string, error) { 194 | dstFilePath := filepath.Join(c.TmpDir, "templates", "dst", f.Name) 195 | srcFilePath := filepath.Join(c.TmpDir, "templates", "src", f.Name) 196 | 197 | srcFile, err := readFileContent(srcFilePath) 198 | if err != nil { 199 | return "", err 200 | } 201 | dstFile, err := readFileContent(dstFilePath) 202 | if err != nil { 203 | return "", err 204 | } 205 | 206 | if c.Masker != nil { 207 | srcFile, err = c.applyMask(srcFile) 208 | if err != nil { 209 | return "", err 210 | } 211 | dstFile, err = c.applyMask(dstFile) 212 | if err != nil { 213 | return "", err 214 | } 215 | } 216 | 217 | edits := myers.ComputeEdits(span.URIFromPath(srcFilePath), string(dstFile), string(srcFile)) 218 | 219 | return fmt.Sprint(gotextdiff.ToUnified(srcFilePath, dstFilePath, string(dstFile), edits)), nil 220 | } 221 | 222 | // applyMask redacts sensitive manifest data when a masker dependency is configured. 223 | func (c *Compare) applyMask(content []byte) ([]byte, error) { 224 | masked, changed, err := c.Masker.Mask(content) 225 | if err != nil { 226 | return nil, fmt.Errorf("mask manifest content: %w", err) 227 | } 228 | if !changed { 229 | return content, nil 230 | } 231 | return masked, nil 232 | } 233 | 234 | // stripHelmLabels removes Helm-managed metadata that would otherwise produce noisy diffs. 235 | func (c *Compare) stripHelmLabels() error { 236 | helmFiles, err := c.Globber.Glob(filepath.Join(c.TmpDir, "**", yamlGlob)) 237 | if err != nil { 238 | return err 239 | } 240 | 241 | for _, helmFile := range helmFiles { 242 | desiredState, err := helpers.StripHelmLabels(helmFile) 243 | if err != nil { 244 | return err 245 | } 246 | if err := helpers.WriteToFile(afero.NewOsFs(), helmFile, desiredState); err != nil { 247 | return err 248 | } 249 | } 250 | 251 | return nil 252 | } 253 | 254 | // readFileContent loads file contents while tolerating missing files. 255 | func readFileContent(path string) ([]byte, error) { 256 | data, err := os.ReadFile(path) // #nosec G304 257 | if errors.Is(err, os.ErrNotExist) { 258 | return nil, nil 259 | } 260 | if err != nil { 261 | return nil, err 262 | } 263 | return data, nil 264 | } 265 | -------------------------------------------------------------------------------- /internal/app/comment_strategy_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/op/go-logging" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | type stubPoster struct { 16 | bodies []string 17 | err error 18 | } 19 | 20 | func (s *stubPoster) Post(body string) error { 21 | s.bodies = append(s.bodies, body) 22 | return s.err 23 | } 24 | 25 | func setupSilentLogger(name string, t *testing.T) *logging.Logger { 26 | logger := logging.MustGetLogger(name) 27 | logging.SetBackend(logging.NewLogBackend(io.Discard, "", 0)) 28 | t.Cleanup(func() { 29 | logging.SetBackend(logging.NewLogBackend(os.Stdout, "", 0)) 30 | }) 31 | return logger 32 | } 33 | 34 | func TestCommentStrategyPresentWithDiff(t *testing.T) { 35 | poster := &stubPoster{} 36 | logger := setupSilentLogger("comment-diff", t) 37 | 38 | strategy := CommentStrategy{ 39 | Log: logger, 40 | Poster: poster, 41 | ShowAdded: true, 42 | ShowRemoved: true, 43 | ApplicationPath: "apps/ingress.yaml", 44 | } 45 | 46 | result := ComparisonResult{ 47 | Added: []DiffOutput{ 48 | {File: File{Name: "/added.yaml"}, Diff: "--- /tmp/src\n+++ /tmp/dst\n+ added"}, 49 | }, 50 | Changed: []DiffOutput{ 51 | {File: File{Name: "changed.yaml"}, Diff: "--- /tmp/src\n+++ /tmp/dst\n@@ diff\n- old\n+ new"}, 52 | }, 53 | Removed: []DiffOutput{ 54 | {File: File{Name: "/removed.yaml"}, Diff: "--- /tmp/src\n+++ /tmp/dst\n- removed"}, 55 | }, 56 | } 57 | 58 | require.NoError(t, strategy.Present(result)) 59 | require.Len(t, poster.bodies, 1) 60 | body := poster.bodies[0] 61 | assert.Contains(t, body, "## Argo Compare Results") 62 | assert.Contains(t, body, "**Application:** `apps/ingress.yaml`") 63 | assert.Contains(t, body, "**Summary**") 64 | assert.Contains(t, body, "- Added: 1") 65 | assert.Contains(t, body, "Added • added.yaml") 66 | assert.Contains(t, body, "Changed • changed.yaml") 67 | assert.Contains(t, body, "Removed • removed.yaml") 68 | assert.Contains(t, body, "```diff") 69 | assert.Contains(t, body, "@@ diff") 70 | assert.NotContains(t, body, "--- /") 71 | assert.NotContains(t, body, "+++ /") 72 | assert.Contains(t, body, "+ added") 73 | assert.Contains(t, body, "- old") 74 | assert.Contains(t, body, "+ new") 75 | assert.Contains(t, body, "- removed") 76 | } 77 | 78 | func TestCommentStrategyPresentNoDiff(t *testing.T) { 79 | poster := &stubPoster{} 80 | logger := setupSilentLogger("comment-empty", t) 81 | 82 | strategy := CommentStrategy{ 83 | Log: logger, 84 | Poster: poster, 85 | ApplicationPath: "apps/foo.yaml", 86 | } 87 | 88 | require.NoError(t, strategy.Present(ComparisonResult{})) 89 | require.Len(t, poster.bodies, 1) 90 | assert.Contains(t, poster.bodies[0], "No manifest differences detected") 91 | assert.Contains(t, poster.bodies[0], "**Application:** `apps/foo.yaml`") 92 | } 93 | 94 | func TestCommentStrategyRequiresPoster(t *testing.T) { 95 | logger := setupSilentLogger("comment-missing", t) 96 | 97 | strategy := CommentStrategy{ 98 | Log: logger, 99 | ApplicationPath: "apps/foo.yaml", 100 | } 101 | 102 | err := strategy.Present(ComparisonResult{}) 103 | require.Error(t, err) 104 | } 105 | 106 | func TestCommentStrategyRequiresLogger(t *testing.T) { 107 | strategy := CommentStrategy{ 108 | Poster: &stubPoster{}, 109 | ApplicationPath: "apps/foo.yaml", 110 | } 111 | 112 | err := strategy.Present(ComparisonResult{}) 113 | require.Error(t, err) 114 | } 115 | 116 | func TestCommentStrategySplitsLargeBody(t *testing.T) { 117 | poster := &stubPoster{} 118 | logger := setupSilentLogger("comment-large", t) 119 | 120 | largeDiff := strings.Repeat("+ oversized line\n", 160000) 121 | 122 | strategy := CommentStrategy{ 123 | Log: logger, 124 | Poster: poster, 125 | ShowAdded: true, 126 | ApplicationPath: "apps/big.yaml", 127 | } 128 | 129 | result := ComparisonResult{ 130 | Added: []DiffOutput{{File: File{Name: "big.yaml"}, Diff: largeDiff}}, 131 | } 132 | 133 | require.NoError(t, strategy.Present(result)) 134 | assert.Greater(t, len(poster.bodies), 1) 135 | assert.Contains(t, poster.bodies[0], "Part 1 of") 136 | assert.Contains(t, poster.bodies[len(poster.bodies)-1], "Part "+fmt.Sprint(len(poster.bodies))+" of "+fmt.Sprint(len(poster.bodies))) 137 | } 138 | 139 | func TestCommentStrategyNotesHiddenSections(t *testing.T) { 140 | poster := &stubPoster{} 141 | logger := setupSilentLogger("comment-hidden", t) 142 | 143 | strategy := CommentStrategy{ 144 | Log: logger, 145 | Poster: poster, 146 | ShowAdded: false, 147 | ShowRemoved: false, 148 | ApplicationPath: "apps/partial.yaml", 149 | } 150 | 151 | result := ComparisonResult{ 152 | Added: []DiffOutput{{File: File{Name: "/new.yaml"}, Diff: "+ new"}}, 153 | Removed: []DiffOutput{{File: File{Name: "/old.yaml"}, Diff: "- old"}}, 154 | } 155 | 156 | require.NoError(t, strategy.Present(result)) 157 | require.Len(t, poster.bodies, 1) 158 | body := poster.bodies[0] 159 | assert.Contains(t, body, "(not shown)") 160 | assert.Contains(t, body, "Added manifests (1) are present but not shown") 161 | assert.Contains(t, body, "Removed manifests (1) are present but not shown") 162 | } 163 | 164 | func TestCommentStrategyStripsDiffHeaders(t *testing.T) { 165 | poster := &stubPoster{} 166 | logger := setupSilentLogger("comment-headers", t) 167 | 168 | strategy := CommentStrategy{ 169 | Log: logger, 170 | Poster: poster, 171 | ApplicationPath: "apps/sample.yaml", 172 | } 173 | 174 | diff := "--- /tmp/argo-compare-123/src.yaml\n+++ /tmp/argo-compare-123/dst.yaml\n@@ diff" 175 | result := ComparisonResult{ 176 | Changed: []DiffOutput{{File: File{Name: "/manifests/sample.yaml"}, Diff: diff}}, 177 | } 178 | 179 | require.NoError(t, strategy.Present(result)) 180 | require.Len(t, poster.bodies, 1) 181 | body := poster.bodies[0] 182 | assert.NotContains(t, body, "--- ") 183 | assert.NotContains(t, body, "+++ ") 184 | assert.Contains(t, body, "@@ diff") 185 | } 186 | 187 | // TestCommentStrategySkipsCRDManifests ensures CRD manifest diffs are replaced 188 | // with a concise notice in posted comments. 189 | func TestCommentStrategySkipsCRDManifests(t *testing.T) { 190 | poster := &stubPoster{} 191 | logger := setupSilentLogger("comment-crd", t) 192 | 193 | strategy := CommentStrategy{ 194 | Log: logger, 195 | Poster: poster, 196 | ApplicationPath: "apps/crd.yaml", 197 | } 198 | 199 | diff := "--- a/crd.yaml\n+++ b/crd.yaml\n+ kind: CustomResourceDefinition\n+ metadata: {}" 200 | result := ComparisonResult{ 201 | Changed: []DiffOutput{{File: File{Name: "/crds/crd.yaml"}, Diff: diff}}, 202 | } 203 | 204 | require.NoError(t, strategy.Present(result)) 205 | require.Len(t, poster.bodies, 1) 206 | body := poster.bodies[0] 207 | assert.Contains(t, body, "**CRD Notes**") 208 | assert.Contains(t, body, "CRD manifest `crds/crd.yaml`") 209 | assert.Contains(t, body, "Diff omitted") 210 | assert.NotContains(t, body, "kind: CustomResourceDefinition") 211 | if lastDetails := strings.LastIndex(body, ""); lastDetails != -1 { 212 | tail := body[lastDetails+len(""):] 213 | assert.Contains(t, tail, "**CRD Notes**", "CRD notes should appear after diff sections") 214 | } 215 | } 216 | 217 | func TestCommentStrategyIgnoresNonCRDManifests(t *testing.T) { 218 | poster := &stubPoster{} 219 | logger := setupSilentLogger("comment-non-crd", t) 220 | 221 | strategy := CommentStrategy{ 222 | Log: logger, 223 | Poster: poster, 224 | ApplicationPath: "apps/credentials.yaml", 225 | } 226 | 227 | diff := "--- a/config/credentials.yaml\n+++ b/config/credentials.yaml\n+ username: demo" 228 | result := ComparisonResult{ 229 | Changed: []DiffOutput{{File: File{Name: "/config/credentials.yaml"}, Diff: diff}}, 230 | } 231 | 232 | require.NoError(t, strategy.Present(result)) 233 | require.Len(t, poster.bodies, 1) 234 | body := poster.bodies[0] 235 | assert.Contains(t, body, "credentials.yaml") 236 | assert.NotContains(t, body, "CRD Notes") 237 | } 238 | -------------------------------------------------------------------------------- /internal/app/app_integration_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/go-git/go-git/v5" 13 | "github.com/go-git/go-git/v5/config" 14 | "github.com/go-git/go-git/v5/plumbing" 15 | "github.com/go-git/go-git/v5/plumbing/object" 16 | "github.com/op/go-logging" 17 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 18 | "github.com/shini4i/argo-compare/internal/models" 19 | "github.com/shini4i/argo-compare/internal/ports" 20 | "github.com/spf13/afero" 21 | "github.com/stretchr/testify/require" 22 | ) 23 | 24 | func TestAppRunIntegration(t *testing.T) { 25 | if testing.Short() { 26 | t.Skip("skip integration test in short mode") 27 | } 28 | 29 | tempDir := t.TempDir() 30 | cacheDir := filepath.Join(tempDir, "cache") 31 | tmpBase := filepath.Join(tempDir, "tmp") 32 | require.NoError(t, os.MkdirAll(tmpBase, 0o755)) 33 | 34 | remoteDir := filepath.Join(tempDir, "origin.git") 35 | _, err := git.PlainInit(remoteDir, true) 36 | require.NoError(t, err) 37 | 38 | workDir := filepath.Join(tempDir, "work") 39 | repo, err := git.PlainInit(workDir, false) 40 | require.NoError(t, err) 41 | 42 | err = repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))) 43 | require.NoError(t, err) 44 | 45 | writeApplication(t, workDir, `1.0.0`, 1) 46 | 47 | worktree, err := repo.Worktree() 48 | require.NoError(t, err) 49 | 50 | _, err = worktree.Add("apps/demo.yaml") 51 | require.NoError(t, err) 52 | 53 | initialHash, err := worktree.Commit("initial commit", &git.CommitOptions{ 54 | Author: defaultSignature(), 55 | }) 56 | require.NoError(t, err) 57 | 58 | _, err = repo.CreateRemote(&config.RemoteConfig{ 59 | Name: "origin", 60 | URLs: []string{remoteDir}, 61 | }) 62 | require.NoError(t, err) 63 | 64 | err = repo.Push(&git.PushOptions{ 65 | RemoteName: "origin", 66 | RefSpecs: []config.RefSpec{"refs/heads/main:refs/heads/main"}, 67 | }) 68 | require.NoError(t, err) 69 | 70 | err = repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName("refs/remotes/origin/main"), initialHash)) 71 | require.NoError(t, err) 72 | 73 | err = worktree.Checkout(&git.CheckoutOptions{ 74 | Branch: plumbing.NewBranchReferenceName("feature/smoke"), 75 | Create: true, 76 | }) 77 | require.NoError(t, err) 78 | 79 | writeApplication(t, workDir, `1.1.0`, 2) 80 | 81 | _, err = worktree.Add("apps/demo.yaml") 82 | require.NoError(t, err) 83 | 84 | _, err = worktree.Commit("update chart version", &git.CommitOptions{ 85 | Author: defaultSignature(), 86 | }) 87 | require.NoError(t, err) 88 | 89 | oldWD, err := os.Getwd() 90 | require.NoError(t, err) 91 | require.NoError(t, os.Chdir(workDir)) 92 | t.Cleanup(func() { 93 | require.NoError(t, os.Chdir(oldWD)) 94 | }) 95 | 96 | var logBuffer bytes.Buffer 97 | testBackend := logging.NewLogBackend(&logBuffer, "", 0) 98 | logging.SetBackend(logging.NewBackendFormatter(testBackend, logging.MustStringFormatter(`%{message}`))) 99 | t.Cleanup(func() { 100 | logging.SetBackend(logging.NewBackendFormatter(logging.NewLogBackend(os.Stdout, "", 0), logging.MustStringFormatter(`%{message}`))) 101 | }) 102 | 103 | logger := logging.MustGetLogger("app-test") 104 | 105 | helmStub := newStubHelmProcessor(t) 106 | cmdStub := &stubCmdRunner{} 107 | 108 | cfg := Config{ 109 | TargetBranch: "main", 110 | CacheDir: cacheDir, 111 | TempDirBase: tmpBase, 112 | PrintAddedManifests: true, 113 | PrintRemovedManifests: true, 114 | Version: "test", 115 | } 116 | 117 | appInstance, err := New(cfg, Dependencies{ 118 | FS: afero.NewOsFs(), 119 | CmdRunner: cmdStub, 120 | FileReader: utils.OsFileReader{}, 121 | HelmProcessor: helmStub, 122 | Globber: utils.CustomGlobber{}, 123 | Logger: logger, 124 | }) 125 | require.NoError(t, err) 126 | 127 | err = appInstance.Run() 128 | require.NoError(t, err) 129 | 130 | require.Equal(t, 2, helmStub.callCount("RenderAppSource")) 131 | require.Equal(t, 2, helmStub.callCount("GenerateValuesFile")) 132 | require.Equal(t, 2, helmStub.callCount("ExtractHelmChart")) 133 | require.Equal(t, 2, helmStub.callCount("DownloadHelmChart")) 134 | 135 | for dir := range helmStub.tmpDirs { 136 | _, statErr := os.Stat(dir) 137 | require.Error(t, statErr) 138 | require.True(t, os.IsNotExist(statErr)) 139 | } 140 | 141 | require.Contains(t, logBuffer.String(), "would be changed") 142 | } 143 | 144 | func writeApplication(t *testing.T, repoDir, version string, replicas int) { 145 | t.Helper() 146 | 147 | content := []byte(`apiVersion: argoproj.io/v1alpha1 148 | kind: Application 149 | metadata: 150 | name: demo 151 | namespace: argocd 152 | spec: 153 | destination: 154 | server: https://kubernetes.default.svc 155 | namespace: demo 156 | source: 157 | repoURL: fake.repo/charts 158 | chart: demo-chart 159 | targetRevision: ` + version + ` 160 | helm: 161 | releaseName: demo 162 | values: | 163 | replicaCount: ` + fmt.Sprintf("%d", replicas) + ` 164 | `) 165 | 166 | appPath := filepath.Join(repoDir, "apps") 167 | require.NoError(t, os.MkdirAll(appPath, 0o755)) 168 | require.NoError(t, os.WriteFile(filepath.Join(appPath, "demo.yaml"), content, 0o644)) 169 | } 170 | 171 | func defaultSignature() *object.Signature { 172 | return &object.Signature{ 173 | Name: "CI Bot", 174 | Email: "ci@example.com", 175 | When: time.Now(), 176 | } 177 | } 178 | 179 | type stubCmdRunner struct{} 180 | 181 | func (s *stubCmdRunner) Run(string, ...string) (string, string, error) { 182 | return "", "", nil 183 | } 184 | 185 | type stubHelmProcessor struct { 186 | t *testing.T 187 | mu sync.Mutex 188 | calls map[string]int 189 | tmpDirs map[string]struct{} 190 | } 191 | 192 | func newStubHelmProcessor(t *testing.T) *stubHelmProcessor { 193 | t.Helper() 194 | return &stubHelmProcessor{ 195 | t: t, 196 | calls: make(map[string]int), 197 | tmpDirs: make(map[string]struct{}), 198 | } 199 | } 200 | 201 | func (s *stubHelmProcessor) record(call, tmpDir string) { 202 | s.mu.Lock() 203 | defer s.mu.Unlock() 204 | s.calls[call]++ 205 | if tmpDir != "" { 206 | s.tmpDirs[tmpDir] = struct{}{} 207 | } 208 | } 209 | 210 | func (s *stubHelmProcessor) callCount(call string) int { 211 | s.mu.Lock() 212 | defer s.mu.Unlock() 213 | return s.calls[call] 214 | } 215 | 216 | func (s *stubHelmProcessor) GenerateValuesFile(chartName, tmpDir, targetType, values string, valuesObject map[string]interface{}) error { 217 | s.record("GenerateValuesFile", tmpDir) 218 | if err := os.MkdirAll(tmpDir, 0o755); err != nil { 219 | return err 220 | } 221 | if values == "" && valuesObject == nil { 222 | values = "replicaCount: 1\n" 223 | } 224 | path := filepath.Join(tmpDir, fmt.Sprintf("%s-values-%s.yaml", chartName, targetType)) 225 | return os.WriteFile(path, []byte(values), 0o600) 226 | } 227 | 228 | func (s *stubHelmProcessor) DownloadHelmChart(_ ports.CmdRunner, _ ports.Globber, _ string, _ string, _ string, _ string, _ []models.RepoCredentials) error { 229 | s.record("DownloadHelmChart", "") 230 | return nil 231 | } 232 | 233 | func (s *stubHelmProcessor) ExtractHelmChart(_ ports.CmdRunner, _ ports.Globber, chartName, chartVersion, _ string, tmpDir, targetType string) error { 234 | s.record("ExtractHelmChart", tmpDir) 235 | dir := filepath.Join(tmpDir, "charts", targetType, chartName) 236 | if err := os.MkdirAll(dir, 0o755); err != nil { 237 | return err 238 | } 239 | content := fmt.Sprintf("chartVersion: %s\n", chartVersion) 240 | return os.WriteFile(filepath.Join(dir, "values.yaml"), []byte(content), 0o644) 241 | } 242 | 243 | func (s *stubHelmProcessor) RenderAppSource(_ ports.CmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType, namespace string) error { 244 | s.record("RenderAppSource", tmpDir) 245 | dir := filepath.Join(tmpDir, "templates", targetType, chartName) 246 | if err := os.MkdirAll(dir, 0o755); err != nil { 247 | return err 248 | } 249 | manifest := fmt.Sprintf(`apiVersion: v1 250 | kind: ConfigMap 251 | metadata: 252 | name: %s 253 | namespace: %s 254 | data: 255 | version: %s 256 | `, releaseName, namespace, chartVersion) 257 | return os.WriteFile(filepath.Join(dir, "manifest.yaml"), []byte(manifest), 0o644) 258 | } 259 | -------------------------------------------------------------------------------- /cmd/argo-compare/command/root.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/shini4i/argo-compare/internal/app" 11 | "github.com/shini4i/argo-compare/internal/helpers" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // ErrCachePurged indicates the user requested cache removal and no further work should run. 16 | var ErrCachePurged = errors.New("cache directory purged") 17 | 18 | // Options describes the collaborators and defaults required to build the CLI. 19 | type Options struct { 20 | Version string 21 | CacheDir string 22 | TempDirBase string 23 | ExternalDiffTool string 24 | RunApp func(app.Config) error 25 | InitLogging func(debug bool) 26 | } 27 | 28 | // Execute builds and runs the Cobra command tree using the supplied options. 29 | func Execute(opts Options, args []string) error { 30 | root := newRootCommand(opts) 31 | 32 | if args != nil { 33 | root.SetArgs(args) 34 | } 35 | 36 | if err := root.Execute(); err != nil { 37 | if errors.Is(err, ErrCachePurged) { 38 | return nil 39 | } 40 | return err 41 | } 42 | 43 | return nil 44 | } 45 | 46 | // newRootCommand builds the root Cobra command with global flags and hooks. 47 | func newRootCommand(opts Options) *cobra.Command { 48 | var ( 49 | debug bool 50 | dropCache bool 51 | ) 52 | 53 | root := &cobra.Command{ 54 | Use: "argo-compare", 55 | Short: "Compare Argo CD applications between git branches", 56 | SilenceUsage: true, 57 | RunE: func(cmd *cobra.Command, args []string) error { 58 | return cmd.Help() 59 | }, 60 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 61 | if opts.InitLogging != nil { 62 | opts.InitLogging(debug) 63 | } 64 | 65 | if dropCache { 66 | fmt.Fprintf(cmd.OutOrStdout(), "===> Purging cache directory: %s\n", opts.CacheDir) 67 | if err := os.RemoveAll(opts.CacheDir); err != nil { 68 | return err 69 | } 70 | return ErrCachePurged 71 | } 72 | 73 | return nil 74 | }, 75 | } 76 | 77 | root.Version = opts.Version 78 | root.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debug mode") 79 | root.PersistentFlags().BoolVar(&dropCache, "drop-cache", false, "Drop cache directory and exit") 80 | 81 | root.AddCommand(newBranchCommand(opts, func() bool { return dropCache }, func() bool { return debug })) 82 | 83 | return root 84 | } 85 | 86 | // newBranchCommand constructs the branch subcommand responsible for manifest comparisons. 87 | func newBranchCommand(opts Options, dropCache func() bool, debug func() bool) *cobra.Command { 88 | flags := loadBranchDefaults() 89 | 90 | cmd := &cobra.Command{ 91 | Use: "branch ", 92 | Short: "Compare Applications against a target branch", 93 | Args: cobra.ExactArgs(1), 94 | RunE: func(cmd *cobra.Command, args []string) error { 95 | if dropCache() { 96 | return nil 97 | } 98 | 99 | params := flags 100 | params.applyFullOutput() 101 | 102 | configOptions, err := params.configOptions(opts, debug()) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | cfg, err := app.NewConfig(args[0], configOptions...) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | if opts.RunApp == nil { 113 | return errors.New("no run handler provided") 114 | } 115 | 116 | return opts.RunApp(cfg) 117 | }, 118 | } 119 | 120 | cmd.Flags().StringVarP(&flags.file, "file", "f", "", "Compare a single file") 121 | cmd.Flags().StringSliceVarP(&flags.ignore, "ignore", "i", nil, "Ignore specific files (can be set multiple times)") 122 | cmd.Flags().BoolVar(&flags.preserveHelmLabels, "preserve-helm-labels", false, "Preserve Helm labels during comparison") 123 | cmd.Flags().BoolVar(&flags.printAdded, "print-added-manifests", false, "Print added manifests") 124 | cmd.Flags().BoolVar(&flags.printRemoved, "print-removed-manifests", false, "Print removed manifests") 125 | cmd.Flags().BoolVar(&flags.fullOutput, "full-output", false, "Print all changed, added, and removed manifests") 126 | cmd.Flags().StringVar(&flags.commentProvider, "comment-provider", flags.commentProvider, "Post diff comment using provider (gitlab)") 127 | cmd.Flags().StringVar(&flags.gitlabURL, "gitlab-url", flags.gitlabURL, "GitLab base URL (e.g., https://gitlab.com)") 128 | cmd.Flags().StringVar(&flags.gitlabToken, "gitlab-token", flags.gitlabToken, "GitLab personal access token") 129 | cmd.Flags().StringVar(&flags.gitlabProjectID, "gitlab-project-id", flags.gitlabProjectID, "GitLab project ID") 130 | cmd.Flags().IntVar(&flags.gitlabMergeIID, "gitlab-merge-request-iid", flags.gitlabMergeIID, "GitLab merge request IID") 131 | 132 | return cmd 133 | } 134 | 135 | type branchFlags struct { 136 | file string 137 | ignore []string 138 | preserveHelmLabels bool 139 | printAdded bool 140 | printRemoved bool 141 | fullOutput bool 142 | commentProvider string 143 | gitlabURL string 144 | gitlabToken string 145 | gitlabProjectID string 146 | gitlabMergeIID int 147 | } 148 | 149 | // loadBranchDefaults gathers branch flag defaults from the environment. 150 | func loadBranchDefaults() branchFlags { 151 | defaults := branchFlags{} 152 | 153 | provider := helpers.GetEnv("ARGO_COMPARE_COMMENT_PROVIDER", "") 154 | if provider == "" && helpers.GetEnv("GITLAB_CI", "") != "" && helpers.GetEnv("CI_MERGE_REQUEST_IID", "") != "" { 155 | provider = string(app.CommentProviderGitLab) 156 | } 157 | defaults.commentProvider = provider 158 | 159 | url := helpers.GetEnv("ARGO_COMPARE_GITLAB_URL", "") 160 | if url == "" { 161 | url = helpers.GetEnv("CI_SERVER_URL", "") 162 | } 163 | defaults.gitlabURL = url 164 | 165 | token := helpers.GetEnv("ARGO_COMPARE_GITLAB_TOKEN", "") 166 | if token == "" { 167 | token = helpers.GetEnv("CI_JOB_TOKEN", "") 168 | } 169 | defaults.gitlabToken = token 170 | 171 | projectID := helpers.GetEnv("ARGO_COMPARE_GITLAB_PROJECT_ID", "") 172 | if projectID == "" { 173 | projectID = helpers.GetEnv("CI_PROJECT_ID", "") 174 | } 175 | defaults.gitlabProjectID = projectID 176 | 177 | mergeIID := helpers.GetEnv("ARGO_COMPARE_GITLAB_MR_IID", "") 178 | if mergeIID == "" { 179 | mergeIID = helpers.GetEnv("CI_MERGE_REQUEST_IID", "") 180 | } 181 | if mergeIID != "" { 182 | if parsed, err := strconv.Atoi(mergeIID); err == nil { 183 | defaults.gitlabMergeIID = parsed 184 | } 185 | } 186 | 187 | return defaults 188 | } 189 | 190 | // applyFullOutput toggles added/removed flags when full output is requested. 191 | func (b *branchFlags) applyFullOutput() { 192 | if b.fullOutput { 193 | b.printAdded = true 194 | b.printRemoved = true 195 | } 196 | } 197 | 198 | // configOptions builds the list of config options based on flag values. 199 | func (b branchFlags) configOptions(opts Options, debugEnabled bool) ([]app.ConfigOption, error) { 200 | options := []app.ConfigOption{ 201 | app.WithFileToCompare(b.file), 202 | app.WithFilesToIgnore(b.ignore), 203 | app.WithPreserveHelmLabels(b.preserveHelmLabels), 204 | app.WithPrintAdded(b.printAdded), 205 | app.WithPrintRemoved(b.printRemoved), 206 | app.WithCacheDir(opts.CacheDir), 207 | app.WithTempDirBase(opts.TempDirBase), 208 | app.WithExternalDiffTool(opts.ExternalDiffTool), 209 | app.WithDebug(debugEnabled), 210 | app.WithVersion(opts.Version), 211 | } 212 | 213 | commentOption, err := b.commentOption() 214 | if err != nil { 215 | return nil, err 216 | } 217 | if commentOption != nil { 218 | options = append(options, commentOption) 219 | } 220 | 221 | return options, nil 222 | } 223 | 224 | // commentOption resolves the comment configuration, if any. 225 | func (b branchFlags) commentOption() (app.ConfigOption, error) { 226 | provider := strings.ToLower(strings.TrimSpace(b.commentProvider)) 227 | switch provider { 228 | case "": 229 | return nil, nil 230 | case string(app.CommentProviderGitLab): 231 | return app.WithCommentConfig(app.CommentConfig{ 232 | Provider: app.CommentProviderGitLab, 233 | GitLab: app.GitLabCommentConfig{ 234 | BaseURL: b.gitlabURL, 235 | Token: b.gitlabToken, 236 | ProjectID: b.gitlabProjectID, 237 | MergeRequestIID: b.gitlabMergeIID, 238 | }, 239 | }), nil 240 | default: 241 | return nil, fmt.Errorf("unsupported comment provider %q", b.commentProvider) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/helm-chart-processor.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | "github.com/shini4i/argo-compare/internal/helpers" 11 | "gopkg.in/yaml.v3" 12 | 13 | "github.com/op/go-logging" 14 | 15 | "github.com/shini4i/argo-compare/internal/models" 16 | "github.com/shini4i/argo-compare/internal/ports" 17 | ) 18 | 19 | var ( 20 | cyan = color.New(color.FgCyan, color.Bold).SprintFunc() 21 | // FailedToDownloadChart indicates Helm failed to pull the requested chart. 22 | FailedToDownloadChart = errors.New("failed to download chart") 23 | ) 24 | 25 | // RealHelmChartProcessor coordinates Helm CLI interactions for chart lifecycle tasks. 26 | type RealHelmChartProcessor struct { 27 | Log *logging.Logger 28 | } 29 | 30 | // GenerateValuesFile creates a Helm values file for a given chart in a specified directory. 31 | // It takes a chart name, a temporary directory for storing the file, the target type categorizing the application, 32 | // and the content of the values file in string format. 33 | // The function first attempts to create the file and writes the provided values content to disk. 34 | func (g RealHelmChartProcessor) GenerateValuesFile(chartName, tmpDir, targetType, values string, valuesObject map[string]interface{}) error { 35 | yamlFile, err := os.Create(fmt.Sprintf("%s/%s-values-%s.yaml", tmpDir, chartName, targetType)) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | defer func(yamlFile *os.File) { 41 | if err := yamlFile.Close(); err != nil { 42 | g.Log.Errorf("failed to close values file %s: %v", yamlFile.Name(), err) 43 | } 44 | }(yamlFile) 45 | 46 | var data []byte 47 | if values != "" { 48 | // Write the 'values' field if it is provided 49 | data = []byte(values) 50 | } else if valuesObject != nil { 51 | // Serialize the 'valuesObject' if it is provided 52 | data, err = yaml.Marshal(valuesObject) 53 | if err != nil { 54 | return err 55 | } 56 | } else { 57 | return errors.New("either 'values' or 'valuesObject' must be provided") 58 | } 59 | 60 | if _, err := yamlFile.Write(data); err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | // DownloadHelmChart fetches a specified version of a Helm chart from a given repository URL and 68 | // stores it in a cache directory. The function leverages the provided CmdRunner to execute 69 | // the helm pull command and Globber to deal with possible non-standard chart naming. 70 | // If the chart is already present in the cache, the function just logs the information and doesn't download it again. 71 | // The function is designed to handle potential errors during directory creation, globbing, and Helm chart downloading. 72 | // Any critical error during these operations terminates the program. 73 | func (g RealHelmChartProcessor) DownloadHelmChart(cmdRunner ports.CmdRunner, globber ports.Globber, cacheDir, repoUrl, chartName, targetRevision string, repoCredentials []models.RepoCredentials) error { 74 | chartLocation := fmt.Sprintf("%s/%s", cacheDir, repoUrl) 75 | 76 | if err := os.MkdirAll(chartLocation, 0750); err != nil { 77 | return fmt.Errorf("failed to create chart cache directory %q: %w", chartLocation, err) 78 | } 79 | 80 | // A bit hacky, but we need to support cases when helm chart tgz filename does not follow the standard naming convention 81 | // For example, sonarqube-4.0.0+315.tgz 82 | chartFileName, err := globber.Glob(fmt.Sprintf("%s/%s-%s*.tgz", chartLocation, chartName, targetRevision)) 83 | if err != nil { 84 | return fmt.Errorf("failed to search for chart %s version %s in %s: %w", chartName, targetRevision, chartLocation, err) 85 | } 86 | 87 | if len(chartFileName) == 0 { 88 | username, password := helpers.FindHelmRepoCredentials(repoUrl, repoCredentials) 89 | 90 | g.Log.Debugf("Downloading version [%s] of [%s] chart...", 91 | cyan(targetRevision), 92 | cyan(chartName)) 93 | 94 | // we assume that if repoUrl does not have protocol, it is an OCI helm registry 95 | // hence we mutate the content of chartName and remove content of repoUrl 96 | if !strings.Contains(repoUrl, "http") { 97 | chartName = fmt.Sprintf("oci://%s/%s", repoUrl, chartName) 98 | repoUrl = "" 99 | } 100 | 101 | stdout, stderr, err := cmdRunner.Run("helm", 102 | "pull", 103 | "--destination", chartLocation, 104 | "--username", username, 105 | "--password", password, 106 | "--repo", repoUrl, 107 | chartName, 108 | "--version", targetRevision) 109 | 110 | if len(stdout) > 0 { 111 | g.Log.Info(stdout) 112 | } 113 | 114 | if len(stderr) > 0 { 115 | g.Log.Error(stderr) 116 | } 117 | 118 | if err != nil { 119 | return FailedToDownloadChart 120 | } 121 | } else { 122 | g.Log.Debugf("Version [%s] of [%s] chart is present in the cache...", 123 | cyan(targetRevision), 124 | cyan(chartName)) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | // ExtractHelmChart extracts a specific version of a Helm chart from a cache directory 131 | // and stores it in a temporary directory. The function uses the provided CmdRunner to 132 | // execute the tar command and Globber to match the chart file in the cache. 133 | // If multiple files matching the pattern are found, an error is returned. 134 | // The function logs any output (standard or error) from the tar command. 135 | // Any critical error during these operations, like directory creation or extraction failure, terminates the program. 136 | func (g RealHelmChartProcessor) ExtractHelmChart(cmdRunner ports.CmdRunner, globber ports.Globber, chartName, chartVersion, chartLocation, tmpDir, targetType string) error { 137 | g.Log.Debugf("Extracting [%s] chart version [%s] to %s/charts/%s...", 138 | cyan(chartName), 139 | cyan(chartVersion), 140 | tmpDir, targetType) 141 | 142 | path := fmt.Sprintf("%s/charts/%s/%s", tmpDir, targetType, chartName) 143 | if err := os.MkdirAll(path, 0750); err != nil { 144 | return fmt.Errorf("failed to create chart extraction directory %q: %w", path, err) 145 | } 146 | 147 | searchPattern := fmt.Sprintf("%s/%s-%s*.tgz", 148 | chartLocation, 149 | chartName, 150 | chartVersion) 151 | 152 | chartFileName, err := globber.Glob(searchPattern) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | if len(chartFileName) == 0 { 158 | return errors.New("chart file not found") 159 | } 160 | 161 | // It's highly unlikely that we will have more than one file matching the pattern 162 | // Nevertheless we need to handle this case, please submit an issue if you encounter this 163 | if len(chartFileName) > 1 { 164 | return errors.New("more than one chart file found, please check your cache directory") 165 | } 166 | 167 | stdout, stderr, err := cmdRunner.Run("tar", 168 | "xf", 169 | chartFileName[0], 170 | "-C", fmt.Sprintf("%s/charts/%s", tmpDir, targetType), 171 | ) 172 | 173 | if len(stdout) > 0 { 174 | g.Log.Info(stdout) 175 | } 176 | 177 | if len(stderr) > 0 { 178 | g.Log.Error(stderr) 179 | } 180 | 181 | return err 182 | } 183 | 184 | // RenderAppSource uses the Helm CLI to render the templates of a given chart. 185 | // It takes a cmdRunner to run the Helm command, a release name for the Helm release, 186 | // the chart name and version, a temporary directory for storing intermediate files, 187 | // and the target type which categorizes the application. 188 | // The function constructs the Helm command with the provided arguments, runs it, and checks for any errors. 189 | // If there are any errors, it returns them. Otherwise, it returns nil. 190 | func (g RealHelmChartProcessor) RenderAppSource(cmdRunner ports.CmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType, namespace string) error { 191 | g.Log.Debugf("Rendering [%s] chart's version [%s] templates using release name [%s]", 192 | cyan(chartName), 193 | cyan(chartVersion), 194 | cyan(releaseName)) 195 | 196 | _, stderr, err := cmdRunner.Run( 197 | "helm", 198 | "template", 199 | "--release-name", releaseName, 200 | fmt.Sprintf("%s/charts/%s/%s", tmpDir, targetType, chartName), 201 | "--output-dir", fmt.Sprintf("%s/templates/%s", tmpDir, targetType), 202 | "--values", fmt.Sprintf("%s/charts/%s/%s/values.yaml", tmpDir, targetType, chartName), 203 | "--values", fmt.Sprintf("%s/%s-values-%s.yaml", tmpDir, chartName, targetType), 204 | "--namespace", namespace, 205 | ) 206 | 207 | if len(stderr) > 0 { 208 | // Helm may emit warnings via stderr even on success; log them for visibility. 209 | g.Log.Error(stderr) 210 | } 211 | 212 | return err 213 | } 214 | -------------------------------------------------------------------------------- /cmd/argo-compare/utils/helm-chart-processor_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/op/go-logging" 12 | "github.com/shini4i/argo-compare/cmd/argo-compare/mocks" 13 | "github.com/shini4i/argo-compare/internal/models" 14 | "github.com/stretchr/testify/assert" 15 | "go.uber.org/mock/gomock" 16 | ) 17 | 18 | func TestGenerateValuesFile(t *testing.T) { 19 | helmChartProcessor := RealHelmChartProcessor{} 20 | 21 | tmpDir := t.TempDir() 22 | 23 | chartName := "ingress-nginx" 24 | targetType := "src" 25 | values := "fullnameOverride: ingress-nginx\ncontroller:\n kind: DaemonSet\n service:\n externalTrafficPolicy: Local\n annotations:\n fancyAnnotation: false\n" 26 | 27 | // Test case 1: Everything works as expected 28 | err := helmChartProcessor.GenerateValuesFile(chartName, tmpDir, targetType, values, nil) 29 | assert.NoError(t, err, "expected no error, got %v", err) 30 | 31 | // Read the generated file 32 | generatedValues, err := os.ReadFile(filepath.Join(tmpDir, chartName+"-values-"+targetType+".yaml")) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | assert.Equal(t, values, string(generatedValues)) 38 | 39 | // Test case 2: Error when creating the file 40 | err = helmChartProcessor.GenerateValuesFile(chartName, "/non/existing/path", targetType, values, nil) 41 | assert.Error(t, err, "expected error, got nil") 42 | } 43 | 44 | func TestDownloadHelmChart(t *testing.T) { 45 | ctrl := gomock.NewController(t) 46 | defer ctrl.Finish() 47 | 48 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")} 49 | cacheDir := t.TempDir() 50 | 51 | // Create the mocks 52 | mockGlobber := mocks.NewMockGlobber(ctrl) 53 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl) 54 | 55 | // Test case 1: chart exists 56 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{filepath.Join(cacheDir, "ingress-nginx-3.34.0.tgz")}, nil) 57 | err := helmChartProcessor.DownloadHelmChart(mockCmdRunner, 58 | mockGlobber, 59 | filepath.Join(cacheDir, "cache"), 60 | "https://chart.example.com", 61 | "ingress-nginx", 62 | "3.34.0", 63 | []models.RepoCredentials{}, 64 | ) 65 | assert.NoError(t, err, "expected no error, got %v", err) 66 | 67 | // Test case 2: chart does not exist, and successfully downloaded 68 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{}, nil) 69 | mockCmdRunner.EXPECT().Run("helm", 70 | "pull", 71 | "--destination", gomock.Any(), 72 | "--username", gomock.Any(), 73 | "--password", gomock.Any(), 74 | "--repo", gomock.Any(), 75 | gomock.Any(), 76 | "--version", gomock.Any()).Return("", "", nil) 77 | err = helmChartProcessor.DownloadHelmChart(mockCmdRunner, 78 | mockGlobber, 79 | filepath.Join(cacheDir, "cache"), 80 | "https://chart.example.com", 81 | "ingress-nginx", 82 | "3.34.0", 83 | []models.RepoCredentials{}, 84 | ) 85 | assert.NoError(t, err, "expected no error, got %v", err) 86 | 87 | // Test case 3: chart does not exist, and failed to download 88 | osErr := &exec.ExitError{ 89 | ProcessState: &os.ProcessState{}, 90 | } 91 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{}, nil) 92 | mockCmdRunner.EXPECT().Run("helm", 93 | "pull", 94 | "--destination", gomock.Any(), 95 | "--username", gomock.Any(), 96 | "--password", gomock.Any(), 97 | "--repo", gomock.Any(), 98 | gomock.Any(), 99 | "--version", gomock.Any()).Return("", "dummy error message", osErr) 100 | err = helmChartProcessor.DownloadHelmChart(mockCmdRunner, 101 | mockGlobber, 102 | filepath.Join(cacheDir, "cache"), 103 | "https://chart.example.com", 104 | "ingress-nginx", 105 | "3.34.0", 106 | []models.RepoCredentials{}, 107 | ) 108 | assert.ErrorIsf(t, err, FailedToDownloadChart, "expected error %v, got %v", FailedToDownloadChart, err) 109 | } 110 | 111 | func TestExtractHelmChart(t *testing.T) { 112 | ctrl := gomock.NewController(t) 113 | defer ctrl.Finish() 114 | 115 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")} 116 | baseDir := t.TempDir() 117 | expectedChartLocation := filepath.Join(baseDir, "cache") 118 | expectedTmpDir := filepath.Join(baseDir, "tmp") 119 | 120 | // Create the mocks 121 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl) 122 | mockGlobber := mocks.NewMockGlobber(ctrl) 123 | 124 | // Set up the expected behavior for the mocks 125 | 126 | // Test case 1: Single chart file found 127 | expectedChartFileName := filepath.Join(baseDir, "charts", "ingress-nginx", "ingress-nginx-3.34.0.tgz") 128 | expectedTargetType := "target" 129 | 130 | // Mock the behavior of the globber 131 | mockGlobber.EXPECT().Glob(fmt.Sprintf("%s/%s-%s*.tgz", expectedChartLocation, "ingress-nginx", "3.34.0")).Return([]string{expectedChartFileName}, nil) 132 | 133 | // Mock the behavior of the cmdRunner 134 | mockCmdRunner.EXPECT().Run("tar", 135 | "xf", 136 | expectedChartFileName, 137 | "-C", 138 | fmt.Sprintf("%s/charts/%s", expectedTmpDir, expectedTargetType), 139 | ).Return("", "", nil) 140 | 141 | err := helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 142 | 143 | assert.NoError(t, err, "expected no error, got %v", err) 144 | 145 | // Test case 2: Multiple chart files found, error expected 146 | expectedChartFilesNames := []string{ 147 | filepath.Join(baseDir, "charts", "sonarqube", "sonarqube-4.0.0+315.tgz"), 148 | filepath.Join(baseDir, "charts", "sonarqube", "sonarqube-4.0.0+316.tgz"), 149 | } 150 | 151 | mockGlobber.EXPECT().Glob(fmt.Sprintf("%s/%s-%s*.tgz", expectedChartLocation, "sonarqube", "4.0.0")).Return(expectedChartFilesNames, nil) 152 | 153 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "sonarqube", "4.0.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 154 | assert.Error(t, err, "expected error, got %v", err) 155 | 156 | // Test case 3: Chart file found, but failed to extract 157 | mockGlobber.EXPECT().Glob(fmt.Sprintf("%s/%s-%s*.tgz", expectedChartLocation, "ingress-nginx", "3.34.0")).Return([]string{expectedChartFileName}, nil) 158 | mockCmdRunner.EXPECT().Run("tar", 159 | "xf", 160 | expectedChartFileName, 161 | "-C", 162 | fmt.Sprintf("%s/charts/%s", expectedTmpDir, expectedTargetType), 163 | ).Return("", "some unexpected error", errors.New("some unexpected error")) 164 | 165 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 166 | assert.Error(t, err, "expected error, got %v", err) 167 | 168 | // Test case 4: zglob failed to run 169 | mockGlobber.EXPECT().Glob(fmt.Sprintf("%s/%s-%s*.tgz", expectedChartLocation, "ingress-nginx", "3.34.0")).Return([]string{}, os.ErrPermission) 170 | 171 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 172 | assert.Error(t, err, "expected error, got %v", err) 173 | 174 | // Test case 5: Failed to find chart file 175 | mockGlobber.EXPECT().Glob(fmt.Sprintf("%s/%s-%s*.tgz", expectedChartLocation, "ingress-nginx", "3.34.0")).Return([]string{}, nil) 176 | 177 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType) 178 | assert.Error(t, err, "expected error, got %v", err) 179 | } 180 | 181 | func TestRenderAppSource(t *testing.T) { 182 | ctrl := gomock.NewController(t) 183 | defer ctrl.Finish() 184 | 185 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")} 186 | 187 | // Create an instance of the mock CmdRunner 188 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl) 189 | 190 | releaseName := "my-release" 191 | chartName := "my-chart" 192 | chartVersion := "1.2.3" 193 | tmpDir := t.TempDir() 194 | targetType := "src" 195 | namespace := "my-namespace" 196 | 197 | // Test case 1: Successful render 198 | mockCmdRunner.EXPECT().Run("helm", 199 | "template", 200 | "--release-name", gomock.Any(), 201 | gomock.Any(), 202 | "--output-dir", gomock.Any(), 203 | "--values", gomock.Any(), 204 | "--values", gomock.Any(), 205 | "--namespace", gomock.Any()).Return("", "", nil) 206 | 207 | // Call the function under test 208 | err := helmChartProcessor.RenderAppSource(mockCmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType, namespace) 209 | assert.NoError(t, err, "expected no error, got %v", err) 210 | 211 | // Test case 2: Failed render 212 | osErr := &exec.ExitError{ 213 | ProcessState: &os.ProcessState{}, 214 | } 215 | mockCmdRunner.EXPECT().Run("helm", 216 | "template", 217 | "--release-name", gomock.Any(), 218 | gomock.Any(), 219 | "--output-dir", gomock.Any(), 220 | "--values", gomock.Any(), 221 | "--values", gomock.Any(), 222 | "--namespace", gomock.Any()).Return("", "", osErr) 223 | 224 | err = helmChartProcessor.RenderAppSource(mockCmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType, namespace) 225 | assert.Error(t, err, "expected error, got nil") 226 | assert.Errorf(t, err, "expected error, got %v", err) 227 | } 228 | -------------------------------------------------------------------------------- /internal/app/compare_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/op/go-logging" 12 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 13 | "github.com/shini4i/argo-compare/internal/sanitizer" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | type failingMasker struct { 19 | err error 20 | } 21 | 22 | // Mask implements ports.SensitiveDataMasker and always returns the configured error. 23 | func (f failingMasker) Mask([]byte) ([]byte, bool, error) { 24 | return nil, false, f.err 25 | } 26 | 27 | const ( 28 | helmDeploymentWithManagedLabels = `# for testing purpose we need only limited fields 29 | apiVersion: apps/v1 30 | kind: Deployment 31 | metadata: 32 | labels: 33 | app.kubernetes.io/instance: traefik-web 34 | app.kubernetes.io/managed-by: Helm 35 | app.kubernetes.io/name: traefik 36 | argocd.argoproj.io/instance: traefik 37 | helm.sh/chart: traefik-23.0.1 38 | name: traefik 39 | namespace: web 40 | ` 41 | expectedStrippedDeployment = `# for testing purpose we need only limited fields 42 | apiVersion: apps/v1 43 | kind: Deployment 44 | metadata: 45 | labels: 46 | app.kubernetes.io/instance: traefik-web 47 | app.kubernetes.io/name: traefik 48 | argocd.argoproj.io/instance: traefik 49 | name: traefik 50 | namespace: web 51 | ` 52 | appManifestYAML = `apiVersion: argoproj.io/v1alpha1 53 | kind: Application 54 | metadata: 55 | name: ingress-nginx 56 | namespace: argo-cd 57 | spec: 58 | source: 59 | repoURL: https://kubernetes.github.io/ingress-nginx 60 | chart: ingress-nginx 61 | targetRevision: "4.2.3" 62 | helm: 63 | values: | 64 | fullnameOverride: ingress-nginx 65 | controller: 66 | kind: DaemonSet 67 | service: 68 | externalTrafficPolicy: Local 69 | annotations: 70 | fancyAnnotation: false 71 | ` 72 | appValuesYAML = `fullnameOverride: ingress-nginx 73 | controller: 74 | kind: DaemonSet 75 | service: 76 | externalTrafficPolicy: Local 77 | annotations: 78 | fancyAnnotation: false 79 | ` 80 | ) 81 | 82 | func TestCompareGenerateFilesStatus(t *testing.T) { 83 | c := Compare{} 84 | 85 | c.srcFiles = []File{ 86 | {Name: "file1", Sha: "1234"}, 87 | {Name: "file3", Sha: "3456"}, 88 | {Name: "file4", Sha: "7890"}, 89 | } 90 | 91 | c.dstFiles = []File{ 92 | {Name: "file1", Sha: "5678"}, 93 | {Name: "file2", Sha: "9012"}, 94 | {Name: "file3", Sha: "3456"}, 95 | } 96 | 97 | c.generateFilesStatus() 98 | 99 | assert.Equal(t, []File{{Name: "file4", Sha: "7890"}}, c.addedFiles) 100 | assert.Equal(t, []File{{Name: "file2", Sha: "9012"}}, c.removedFiles) 101 | assert.Equal(t, []File{{Name: "file1", Sha: "1234"}}, c.diffFiles) 102 | } 103 | 104 | func TestCompareFindAndStripHelmLabels(t *testing.T) { 105 | tmpDir := t.TempDir() 106 | testFile := filepath.Join(tmpDir, "deployment.yaml") 107 | require.NoError(t, os.WriteFile(testFile, []byte(helmDeploymentWithManagedLabels), 0o644)) 108 | 109 | c := &Compare{ 110 | Globber: utils.CustomGlobber{}, 111 | TmpDir: tmpDir, 112 | } 113 | 114 | require.NoError(t, c.stripHelmLabels()) 115 | 116 | modified, err := os.ReadFile(testFile) 117 | require.NoError(t, err) 118 | 119 | assert.Equal(t, expectedStrippedDeployment, string(modified)) 120 | } 121 | 122 | func TestCompareProcessFiles(t *testing.T) { 123 | tmpDir := t.TempDir() 124 | srcDir := filepath.Join(tmpDir, "templates", "src") 125 | require.NoError(t, os.MkdirAll(srcDir, 0o755)) 126 | 127 | file1 := filepath.Join(srcDir, "test.yaml") 128 | file2 := filepath.Join(srcDir, "test-values.yaml") 129 | require.NoError(t, os.WriteFile(file1, []byte(appManifestYAML), 0o644)) 130 | require.NoError(t, os.WriteFile(file2, []byte(appValuesYAML), 0o644)) 131 | 132 | c := &Compare{TmpDir: tmpDir} 133 | 134 | files := []string{file1, file2} 135 | found, err := c.processFiles(files, "src") 136 | require.NoError(t, err) 137 | 138 | assert.Len(t, found, 2) 139 | assert.Equal(t, strings.TrimPrefix(file1, filepath.Join(tmpDir, "templates", "src")), found[0].Name) 140 | assert.NotEmpty(t, found[0].Sha) 141 | assert.Equal(t, strings.TrimPrefix(file2, filepath.Join(tmpDir, "templates", "src")), found[1].Name) 142 | assert.NotEmpty(t, found[1].Sha) 143 | } 144 | 145 | func TestStdoutStrategyPresent(t *testing.T) { 146 | var buf bytes.Buffer 147 | backend := logging.NewLogBackend(&buf, "", 0) 148 | logging.SetBackend(logging.NewBackendFormatter(backend, logging.MustStringFormatter(`%{message}`))) 149 | t.Cleanup(func() { 150 | logging.SetBackend(logging.NewBackendFormatter(logging.NewLogBackend(os.Stdout, "", 0), logging.MustStringFormatter(`%{message}`))) 151 | }) 152 | 153 | strategy := StdoutStrategy{ 154 | Log: logging.MustGetLogger("compare-print"), 155 | ShowAdded: true, 156 | ShowRemoved: true, 157 | } 158 | 159 | result := ComparisonResult{} 160 | require.NoError(t, strategy.Present(result)) 161 | assert.Contains(t, buf.String(), "No diff was found in rendered manifests!") 162 | 163 | buf.Reset() 164 | 165 | result = ComparisonResult{ 166 | Added: []DiffOutput{{File: File{Name: "file1"}, Diff: "diff-added"}}, 167 | Removed: []DiffOutput{{File: File{Name: "file2"}, Diff: "diff-removed"}}, 168 | Changed: []DiffOutput{{File: File{Name: "file3"}, Diff: "diff-changed"}}, 169 | } 170 | 171 | require.NoError(t, strategy.Present(result)) 172 | logs := buf.String() 173 | assert.Contains(t, logs, "The following 1 file would be added") 174 | assert.Contains(t, logs, "The following 1 file would be removed") 175 | assert.Contains(t, logs, "The following 1 file would be changed") 176 | assert.Contains(t, logs, "file1") 177 | assert.Contains(t, logs, "file2") 178 | assert.Contains(t, logs, "file3") 179 | } 180 | 181 | func TestCompareExecuteProducesDiffs(t *testing.T) { 182 | tmpDir := t.TempDir() 183 | srcDir := filepath.Join(tmpDir, "templates", "src") 184 | dstDir := filepath.Join(tmpDir, "templates", "dst") 185 | require.NoError(t, os.MkdirAll(srcDir, 0o755)) 186 | require.NoError(t, os.MkdirAll(dstDir, 0o755)) 187 | 188 | write := func(dir, name, content string) { 189 | require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) 190 | } 191 | 192 | write(srcDir, "added.yaml", "kind: ConfigMap\nmetadata:\n name: added\n") 193 | write(dstDir, "removed.yaml", "kind: ConfigMap\nmetadata:\n name: removed\n") 194 | write(srcDir, "changed.yaml", "kind: ConfigMap\nmetadata:\n name: changed\n labels:\n side: src\n") 195 | write(dstDir, "changed.yaml", "kind: ConfigMap\nmetadata:\n name: changed\n labels:\n side: dst\n") 196 | 197 | compare := Compare{ 198 | Globber: utils.CustomGlobber{}, 199 | TmpDir: tmpDir, 200 | PreserveHelmLabels: true, 201 | } 202 | 203 | result, err := compare.Execute() 204 | require.NoError(t, err) 205 | 206 | require.Len(t, result.Added, 1) 207 | assert.Equal(t, "/added.yaml", result.Added[0].File.Name) 208 | require.Len(t, result.Removed, 1) 209 | assert.Equal(t, "/removed.yaml", result.Removed[0].File.Name) 210 | require.Len(t, result.Changed, 1) 211 | assert.Equal(t, "/changed.yaml", result.Changed[0].File.Name) 212 | assert.Contains(t, result.Changed[0].Diff, "- side: dst") 213 | assert.Contains(t, result.Changed[0].Diff, "+ side: src") 214 | } 215 | 216 | // TestCompareExecuteMasksSecretDiff ensures secret diffs redact sensitive values before presentation. 217 | func TestCompareExecuteMasksSecretDiff(t *testing.T) { 218 | tmpDir := t.TempDir() 219 | srcDir := filepath.Join(tmpDir, "templates", "src") 220 | dstDir := filepath.Join(tmpDir, "templates", "dst") 221 | require.NoError(t, os.MkdirAll(srcDir, 0o755)) 222 | require.NoError(t, os.MkdirAll(dstDir, 0o755)) 223 | 224 | srcSecret := `apiVersion: v1 225 | kind: Secret 226 | metadata: 227 | name: sample 228 | type: Opaque 229 | data: 230 | password: c2VjcmV0 231 | ` 232 | dstSecret := `apiVersion: v1 233 | kind: Secret 234 | metadata: 235 | name: sample 236 | type: Opaque 237 | data: 238 | password: ZGlmZmVyZW50 239 | ` 240 | 241 | require.NoError(t, os.WriteFile(filepath.Join(srcDir, "secret.yaml"), []byte(srcSecret), 0o644)) 242 | require.NoError(t, os.WriteFile(filepath.Join(dstDir, "secret.yaml"), []byte(dstSecret), 0o644)) 243 | 244 | compare := Compare{ 245 | Globber: utils.CustomGlobber{}, 246 | TmpDir: tmpDir, 247 | PreserveHelmLabels: true, 248 | Masker: sanitizer.NewKubernetesSecretMasker(), 249 | } 250 | 251 | result, err := compare.Execute() 252 | require.NoError(t, err) 253 | require.Len(t, result.Changed, 1) 254 | 255 | diff := result.Changed[0].Diff 256 | 257 | assert.NotContains(t, diff, "c2VjcmV0") 258 | assert.NotContains(t, diff, "ZGlmZmVyZW50") 259 | assert.Contains(t, diff, "ENC[sha256:") 260 | assert.Contains(t, diff, "- password: ENC[sha256:") 261 | assert.Contains(t, diff, "+ password: ENC[sha256:") 262 | } 263 | 264 | // TestCompareGenerateDiffMaskError verifies masking failures are surfaced with context. 265 | func TestCompareGenerateDiffMaskError(t *testing.T) { 266 | tmpDir := t.TempDir() 267 | srcDir := filepath.Join(tmpDir, "templates", "src") 268 | dstDir := filepath.Join(tmpDir, "templates", "dst") 269 | require.NoError(t, os.MkdirAll(srcDir, 0o755)) 270 | require.NoError(t, os.MkdirAll(dstDir, 0o755)) 271 | 272 | content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: demo\n") 273 | require.NoError(t, os.WriteFile(filepath.Join(srcDir, "demo.yaml"), content, 0o644)) 274 | require.NoError(t, os.WriteFile(filepath.Join(dstDir, "demo.yaml"), content, 0o644)) 275 | 276 | maskErr := fmt.Errorf("simulated masking failure") 277 | compare := Compare{ 278 | TmpDir: tmpDir, 279 | Masker: failingMasker{err: maskErr}, 280 | } 281 | 282 | _, err := compare.generateDiff(File{Name: "/demo.yaml"}) 283 | require.Error(t, err) 284 | assert.Contains(t, err.Error(), "mask manifest content") 285 | assert.Contains(t, err.Error(), maskErr.Error()) 286 | } 287 | -------------------------------------------------------------------------------- /internal/app/git.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/go-git/go-git/v5" 10 | "github.com/go-git/go-git/v5/plumbing" 11 | "github.com/go-git/go-git/v5/plumbing/object" 12 | "github.com/op/go-logging" 13 | "github.com/shini4i/argo-compare/internal/helpers" 14 | "github.com/shini4i/argo-compare/internal/models" 15 | "github.com/shini4i/argo-compare/internal/ports" 16 | "github.com/spf13/afero" 17 | ) 18 | 19 | // GitRepo wraps interactions with the current repository for diff analysis. 20 | type GitRepo struct { 21 | repo *git.Repository 22 | fs afero.Fs 23 | cmdRunner ports.CmdRunner 24 | fileReader ports.FileReader 25 | log *logging.Logger 26 | } 27 | 28 | // ChangedFilesResult encapsulates the changed application files and any invalid manifests. 29 | type ChangedFilesResult struct { 30 | Applications []string 31 | Invalid []string 32 | } 33 | 34 | var gitFileDoesNotExist = errors.New("file does not exist in target branch") 35 | 36 | // NewGitRepo opens the current repository and prepares helpers for git operations. 37 | func NewGitRepo(fs afero.Fs, cmdRunner ports.CmdRunner, fileReader ports.FileReader, log *logging.Logger) (*GitRepo, error) { 38 | repoRoot, err := GetGitRepoRoot() 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | repo, err := git.PlainOpen(repoRoot) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to open repository: %v", err) 46 | } 47 | 48 | return &GitRepo{ 49 | repo: repo, 50 | fs: fs, 51 | cmdRunner: cmdRunner, 52 | fileReader: fileReader, 53 | log: log, 54 | }, nil 55 | } 56 | 57 | // GetChangedFiles compares HEAD against targetBranch and returns changed application files. 58 | func (g *GitRepo) GetChangedFiles(targetBranch string, filesToIgnore []string) (ChangedFilesResult, error) { 59 | targetRef, err := g.repo.Reference(plumbing.ReferenceName(fmt.Sprintf("refs/remotes/origin/%s", targetBranch)), true) 60 | if err != nil { 61 | return ChangedFilesResult{}, fmt.Errorf("failed to resolve target branch %s: %v", targetBranch, err) 62 | } 63 | 64 | headRef, err := g.repo.Head() 65 | if err != nil { 66 | return ChangedFilesResult{}, fmt.Errorf("failed to get HEAD: %v", err) 67 | } 68 | 69 | targetCommit, err := g.repo.CommitObject(targetRef.Hash()) 70 | if err != nil { 71 | return ChangedFilesResult{}, fmt.Errorf("failed to get commit object for target branch %s: %v", targetBranch, err) 72 | } 73 | 74 | headCommit, err := g.repo.CommitObject(headRef.Hash()) 75 | if err != nil { 76 | return ChangedFilesResult{}, fmt.Errorf("failed to get commit object for current branch: %v", err) 77 | } 78 | 79 | targetTree, err := targetCommit.Tree() 80 | if err != nil { 81 | return ChangedFilesResult{}, fmt.Errorf("failed to get tree for target commit: %v", err) 82 | } 83 | 84 | headTree, err := headCommit.Tree() 85 | if err != nil { 86 | return ChangedFilesResult{}, fmt.Errorf("failed to get tree for head commit: %v", err) 87 | } 88 | 89 | changes, err := object.DiffTree(targetTree, headTree) 90 | if err != nil { 91 | return ChangedFilesResult{}, fmt.Errorf("failed to get diff between trees: %v", err) 92 | } 93 | 94 | var foundFiles, removedFiles []string 95 | for _, change := range changes { 96 | if change.To.Name == "" { 97 | removedFiles = append(removedFiles, change.From.Name) 98 | continue 99 | } 100 | foundFiles = append(foundFiles, change.To.Name) 101 | } 102 | 103 | g.printChangeFile(foundFiles, removedFiles) 104 | 105 | applications, invalid := g.sortChangedFiles(foundFiles) 106 | filtered := filterIgnored(applications, filesToIgnore) 107 | 108 | return ChangedFilesResult{Applications: filtered, Invalid: invalid}, nil 109 | } 110 | 111 | // GetChangedFileContent fetches and parses targetFile from targetBranch. 112 | func (g *GitRepo) GetChangedFileContent(targetBranch, targetFile string, printAdded bool) (models.Application, error) { 113 | g.log.Debugf("Getting content of %s from %s", targetFile, targetBranch) 114 | 115 | targetTree, err := g.treeForBranch(targetBranch) 116 | if err != nil { 117 | return models.Application{}, err 118 | } 119 | 120 | fileContent, err := g.targetFileContent(targetTree, targetBranch, targetFile, printAdded) 121 | if err != nil { 122 | return models.Application{}, err 123 | } 124 | 125 | return g.parseTargetApplication(fileContent) 126 | } 127 | 128 | // treeForBranch resolves the Git tree for the provided remote branch reference. 129 | func (g *GitRepo) treeForBranch(targetBranch string) (*object.Tree, error) { 130 | targetRef, err := g.repo.Reference(plumbing.ReferenceName("refs/remotes/origin/"+targetBranch), true) 131 | if err != nil { 132 | return nil, fmt.Errorf("failed to resolve target branch %s: %v", targetBranch, err) 133 | } 134 | 135 | targetCommit, err := g.repo.CommitObject(targetRef.Hash()) 136 | if err != nil { 137 | return nil, fmt.Errorf("failed to get commit object for target branch %s: %v", targetBranch, err) 138 | } 139 | 140 | targetTree, err := targetCommit.Tree() 141 | if err != nil { 142 | return nil, fmt.Errorf("failed to get tree for target commit: %v", err) 143 | } 144 | 145 | return targetTree, nil 146 | } 147 | 148 | // targetFileContent retrieves the contents of a manifest from the target branch, respecting print options. 149 | func (g *GitRepo) targetFileContent(targetTree *object.Tree, targetBranch, targetFile string, printAdded bool) (string, error) { 150 | fileEntry, err := targetTree.File(targetFile) 151 | if err != nil { 152 | if errors.Is(err, object.ErrFileNotFound) { 153 | g.log.Warning(yellow(fmt.Sprintf("The requested file %s does not exist in target branch %s, assuming it is a new Application", targetFile, targetBranch))) 154 | if !printAdded { 155 | return "", gitFileDoesNotExist 156 | } 157 | return "", nil 158 | } 159 | return "", fmt.Errorf("failed to find file %s in target branch %s: %v", targetFile, targetBranch, err) 160 | } 161 | 162 | if fileEntry == nil { 163 | return "", nil 164 | } 165 | 166 | fileContent, err := fileEntry.Contents() 167 | if err != nil { 168 | return "", fmt.Errorf("failed to get contents of file %s: %v", targetFile, err) 169 | } 170 | 171 | return fileContent, nil 172 | } 173 | 174 | // parseTargetApplication parses the retrieved manifest content into an Application model. 175 | func (g *GitRepo) parseTargetApplication(fileContent string) (models.Application, error) { 176 | tmpFile, err := helpers.CreateTempFile(g.fs, fileContent) 177 | if err != nil { 178 | return models.Application{}, err 179 | } 180 | 181 | defer func(file afero.File) { 182 | if err := afero.Fs.Remove(g.fs, file.Name()); err != nil { 183 | g.log.Errorf("Failed to remove temporary file [%s]: %s", file.Name(), err) 184 | } 185 | }(tmpFile) 186 | 187 | target := Target{ 188 | CmdRunner: g.cmdRunner, 189 | FileReader: g.fileReader, 190 | Log: g.log, 191 | File: tmpFile.Name(), 192 | } 193 | if err := target.parse(); err != nil { 194 | return models.Application{}, fmt.Errorf("failed to parse the application: %w", err) 195 | } 196 | 197 | return target.App, nil 198 | } 199 | 200 | // printChangeFile reports the lists of added and removed files at debug level. 201 | func (g *GitRepo) printChangeFile(addedFiles, removed []string) { 202 | g.log.Debug("===> Found the following changed files:") 203 | for _, file := range addedFiles { 204 | if file != "" { 205 | g.log.Debugf("▶ %s", file) 206 | } 207 | } 208 | g.log.Debug("===> Found the following removed files:") 209 | for _, file := range removed { 210 | if file != "" { 211 | g.log.Debugf("▶ %s", red(file)) 212 | } 213 | } 214 | } 215 | 216 | // sortChangedFiles filters diff results to include only valid Application manifests. 217 | func (g *GitRepo) sortChangedFiles(files []string) (applications []string, invalid []string) { 218 | for _, file := range files { 219 | if filepath.Ext(file) != ".yaml" { 220 | continue 221 | } 222 | 223 | switch isApp, err := g.checkIfApp(file); { 224 | case errors.Is(err, models.NotApplicationError): 225 | g.log.Debugf("Skipping non-application file [%s]", file) 226 | case errors.Is(err, models.UnsupportedAppConfigurationError): 227 | g.log.Warningf("Skipping unsupported application configuration [%s]", file) 228 | case errors.Is(err, models.EmptyFileError): 229 | g.log.Debugf("Skipping empty file [%s]", file) 230 | case err != nil: 231 | g.log.Errorf("Error checking if [%s] is an Application: %s", file, err) 232 | invalid = append(invalid, file) 233 | case isApp: 234 | applications = append(applications, file) 235 | } 236 | } 237 | 238 | if len(applications) > 0 { 239 | g.log.Info("===> Found the following changed Application files") 240 | for _, file := range applications { 241 | g.log.Infof("▶ %s", yellow(file)) 242 | } 243 | } 244 | 245 | return applications, invalid 246 | } 247 | 248 | // checkIfApp determines whether the provided path points to a valid Application manifest. 249 | func (g *GitRepo) checkIfApp(file string) (bool, error) { 250 | g.log.Debugf("===> Checking if [%s] is an Application", cyan(file)) 251 | 252 | target := Target{ 253 | CmdRunner: g.cmdRunner, 254 | FileReader: g.fileReader, 255 | Log: g.log, 256 | File: file, 257 | } 258 | 259 | if err := target.parse(); err != nil { 260 | return false, err 261 | } 262 | return true, nil 263 | } 264 | 265 | // GetGitRepoRoot walks up from the working directory to find the repository root. 266 | func GetGitRepoRoot() (string, error) { 267 | dir, err := os.Getwd() 268 | if err != nil { 269 | return "", fmt.Errorf("failed to get current working directory: %v", err) 270 | } 271 | 272 | for { 273 | _, err := git.PlainOpen(dir) 274 | if err == nil { 275 | return dir, nil 276 | } 277 | 278 | parentDir := filepath.Dir(dir) 279 | if parentDir == dir { 280 | break 281 | } 282 | 283 | dir = parentDir 284 | } 285 | 286 | return "", fmt.Errorf("no git repository found") 287 | } 288 | 289 | // filterIgnored returns all files that are not present in the ignored list. 290 | func filterIgnored(files []string, ignored []string) []string { 291 | if len(ignored) == 0 { 292 | return files 293 | } 294 | 295 | ignoredSet := make(map[string]struct{}, len(ignored)) 296 | for _, file := range ignored { 297 | ignoredSet[file] = struct{}{} 298 | } 299 | 300 | var filtered []string 301 | for _, file := range files { 302 | if _, ok := ignoredSet[file]; ok { 303 | continue 304 | } 305 | filtered = append(filtered, file) 306 | } 307 | 308 | return filtered 309 | } 310 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 | github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= 7 | github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 13 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 14 | github.com/codingsince1985/checksum v1.3.0 h1:kqqIqWBwjidGmt/pO4yXCEX+np7HACGx72EB+MkKcVY= 15 | github.com/codingsince1985/checksum v1.3.0/go.mod h1:QfRskdtdWap+gJil8e5obw6I8/cWJ0SwMUACruWDSU8= 16 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 17 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 18 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 19 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 21 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 22 | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 23 | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 24 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 25 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 26 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 27 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 28 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 29 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 30 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 31 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 32 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 33 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 34 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 35 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 36 | github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8= 37 | github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 38 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 39 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 40 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 41 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 42 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 43 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 44 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 45 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 46 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 47 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 48 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 49 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 50 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 51 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 52 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 53 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 54 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 55 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 56 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 57 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 58 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 59 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 60 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 61 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 62 | github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A= 63 | github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= 64 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 65 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 66 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 67 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 68 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= 69 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= 70 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 71 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 72 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 73 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 74 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 75 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 76 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 77 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 78 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 79 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 80 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 81 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 82 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 83 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 84 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 85 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 86 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 87 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 88 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 89 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 90 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 91 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 92 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 93 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 94 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 95 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 96 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 97 | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= 98 | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 99 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 100 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 101 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 102 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 103 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 104 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 105 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 106 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 107 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 108 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 109 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 110 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 111 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 113 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 114 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 115 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 116 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 117 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 118 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 119 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 120 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 121 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 122 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 123 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 124 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 125 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 126 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 127 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 128 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 129 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 130 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 131 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 132 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 133 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 134 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/op/go-logging" 11 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils" 12 | "github.com/shini4i/argo-compare/internal/comment" 13 | "github.com/shini4i/argo-compare/internal/comment/gitlab" 14 | "github.com/shini4i/argo-compare/internal/models" 15 | "github.com/shini4i/argo-compare/internal/ports" 16 | "github.com/shini4i/argo-compare/internal/sanitizer" 17 | "github.com/spf13/afero" 18 | ) 19 | 20 | const repoCredsPrefix = "REPO_CREDS_" // #nosec G101 21 | 22 | // Dependencies aggregates runtime collaborators required by App. 23 | type Dependencies struct { 24 | FS afero.Fs 25 | CmdRunner ports.CmdRunner 26 | FileReader ports.FileReader 27 | HelmProcessor ports.HelmChartsProcessor 28 | Globber ports.Globber 29 | Logger *logging.Logger 30 | CommentPosterFactory CommentPosterFactory 31 | SensitiveDataMasker ports.SensitiveDataMasker // Responsible for redacting sensitive manifest fields. 32 | } 33 | 34 | // App orchestrates the end-to-end comparison workflow. 35 | type App struct { 36 | cfg Config 37 | fs afero.Fs 38 | cmdRunner ports.CmdRunner 39 | fileReader ports.FileReader 40 | helmProcessor ports.HelmChartsProcessor 41 | globber ports.Globber 42 | logger *logging.Logger 43 | repoCredentials []models.RepoCredentials 44 | commentFactory CommentPosterFactory 45 | sensitiveDataMasker ports.SensitiveDataMasker // Applied to manifest content prior to diff generation. 46 | } 47 | 48 | // CommentPosterFactory builds a comment poster based on the active configuration. 49 | type CommentPosterFactory func(cfg Config) (comment.Poster, error) 50 | 51 | // New constructs an App using the supplied configuration and dependencies. 52 | func New(cfg Config, deps Dependencies) (*App, error) { 53 | if cfg.CacheDir == "" { 54 | return nil, errors.New("cache directory must be provided") 55 | } 56 | 57 | if deps.FS == nil { 58 | deps.FS = afero.NewOsFs() 59 | } 60 | if deps.CmdRunner == nil { 61 | deps.CmdRunner = &utils.RealCmdRunner{} 62 | } 63 | if deps.FileReader == nil { 64 | deps.FileReader = utils.OsFileReader{} 65 | } 66 | if deps.HelmProcessor == nil { 67 | deps.HelmProcessor = utils.RealHelmChartProcessor{Log: deps.Logger} 68 | } 69 | if deps.Globber == nil { 70 | deps.Globber = utils.CustomGlobber{} 71 | } 72 | if deps.Logger == nil { 73 | return nil, errors.New("logger must be provided") 74 | } 75 | if deps.CommentPosterFactory == nil { 76 | deps.CommentPosterFactory = defaultCommentPosterFactory 77 | } 78 | if deps.SensitiveDataMasker == nil { 79 | deps.SensitiveDataMasker = sanitizer.NewKubernetesSecretMasker() 80 | } 81 | 82 | return &App{ 83 | cfg: cfg, 84 | fs: deps.FS, 85 | cmdRunner: deps.CmdRunner, 86 | fileReader: deps.FileReader, 87 | helmProcessor: deps.HelmProcessor, 88 | globber: deps.Globber, 89 | logger: deps.Logger, 90 | commentFactory: deps.CommentPosterFactory, 91 | sensitiveDataMasker: deps.SensitiveDataMasker, 92 | }, nil 93 | } 94 | 95 | // Run executes the comparison workflow and returns any terminal error. 96 | func (a *App) Run() error { 97 | if err := a.collectRepoCredentials(); err != nil { 98 | return err 99 | } 100 | 101 | repo, err := NewGitRepo(a.fs, a.cmdRunner, a.fileReader, a.logger) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | a.logger.Infof("===> Running Argo Compare version [%s]", cyan(a.cfg.Version)) 107 | 108 | var ( 109 | changedFiles []string 110 | invalidFiles []string 111 | ) 112 | 113 | if a.cfg.FileToCompare != "" { 114 | changedFiles = filterIgnored([]string{a.cfg.FileToCompare}, a.cfg.FilesToIgnore) 115 | if len(changedFiles) == 0 { 116 | a.logger.Infof("Specified file [%s] ignored by filters. Exiting...", a.cfg.FileToCompare) 117 | return nil 118 | } 119 | } else { 120 | var result ChangedFilesResult 121 | result, err = repo.GetChangedFiles(a.cfg.TargetBranch, a.cfg.FilesToIgnore) 122 | if err != nil { 123 | return err 124 | } 125 | changedFiles = result.Applications 126 | invalidFiles = result.Invalid 127 | } 128 | 129 | if len(changedFiles) == 0 { 130 | a.logger.Info("No changed Application files found. Exiting...") 131 | return nil 132 | } 133 | 134 | if err := a.compareFiles(repo, changedFiles); err != nil { 135 | return err 136 | } 137 | 138 | return a.reportInvalidFiles(invalidFiles) 139 | } 140 | 141 | // compareFiles renders and evaluates each changed Application manifest against the target branch. 142 | func (a *App) compareFiles(repo *GitRepo, changedFiles []string) error { 143 | for _, file := range changedFiles { 144 | if err := a.processChangedFile(repo, file); err != nil { 145 | return err 146 | } 147 | } 148 | return nil 149 | } 150 | 151 | type destinationAction int 152 | 153 | const ( 154 | destinationSkip destinationAction = iota 155 | destinationNone 156 | destinationProcess 157 | ) 158 | 159 | // processChangedFile orchestrates comparison for a single manifest, optionally skipping targets. 160 | func (a *App) processChangedFile(repo *GitRepo, file string) (err error) { 161 | a.logger.Infof("===> Processing changed application: [%s]", cyan(file)) 162 | 163 | tmpDir, err := afero.TempDir(a.fs, a.cfg.TempDirBase, "argo-compare-") 164 | if err != nil { 165 | return err 166 | } 167 | 168 | defer func() { 169 | if removeErr := (afero.Afero{Fs: a.fs}).RemoveAll(tmpDir); err == nil && removeErr != nil { 170 | err = removeErr 171 | } 172 | }() 173 | 174 | if err = a.processFile(file, "src", models.Application{}, tmpDir); err != nil { 175 | return err 176 | } 177 | 178 | targetApp, action, err := a.resolveTargetApplication(repo, file) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | if action == destinationSkip { 184 | return nil 185 | } 186 | 187 | if action == destinationProcess { 188 | if destErr := a.processFile(file, "dst", targetApp, tmpDir); destErr != nil && !a.cfg.PrintAddedManifests { 189 | return destErr 190 | } 191 | } 192 | 193 | return a.runComparison(tmpDir, file) 194 | } 195 | 196 | // resolveTargetApplication retrieves the target branch manifest and determines follow-up actions. 197 | func (a *App) resolveTargetApplication(repo *GitRepo, file string) (models.Application, destinationAction, error) { 198 | app, err := repo.GetChangedFileContent(a.cfg.TargetBranch, file, a.cfg.PrintAddedManifests) 199 | 200 | switch { 201 | case errors.Is(err, gitFileDoesNotExist) && !a.cfg.PrintAddedManifests: 202 | return models.Application{}, destinationSkip, nil 203 | case errors.Is(err, models.EmptyFileError): 204 | return models.Application{}, destinationNone, nil 205 | case err != nil: 206 | a.logger.Errorf("Could not get the target Application from branch [%s]: %s", a.cfg.TargetBranch, err) 207 | return app, destinationProcess, nil 208 | default: 209 | return app, destinationProcess, nil 210 | } 211 | } 212 | 213 | // processFile prepares Helm inputs for a single manifest and renders its templates. 214 | func (a *App) processFile(fileName string, fileType string, application models.Application, tmpDir string) error { 215 | target := Target{ 216 | CmdRunner: a.cmdRunner, 217 | FileReader: a.fileReader, 218 | HelmProcessor: a.helmProcessor, 219 | CacheDir: a.cfg.CacheDir, 220 | TmpDir: tmpDir, 221 | RepoCredentials: a.repoCredentials, 222 | Log: a.logger, 223 | File: fileName, 224 | Type: fileType, 225 | App: application, 226 | } 227 | 228 | if fileType == "src" { 229 | if err := target.parse(); err != nil { 230 | return err 231 | } 232 | } 233 | 234 | if err := target.generateValuesFiles(); err != nil { 235 | return err 236 | } 237 | 238 | if err := target.ensureHelmCharts(); err != nil { 239 | return err 240 | } 241 | 242 | if err := target.extractCharts(); err != nil { 243 | return err 244 | } 245 | 246 | return target.renderAppSources() 247 | } 248 | 249 | // runComparison executes the diff strategy for the prepared temporary workspace. 250 | func (a *App) runComparison(tmpDir, applicationFile string) error { 251 | comparer := Compare{ 252 | Globber: a.globber, 253 | TmpDir: tmpDir, 254 | PreserveHelmLabels: a.cfg.PreserveHelmLabels, 255 | Masker: a.sensitiveDataMasker, 256 | } 257 | 258 | result, err := comparer.Execute() 259 | if err != nil { 260 | return err 261 | } 262 | 263 | strategies, err := a.selectDiffStrategies(applicationFile) 264 | if err != nil { 265 | return err 266 | } 267 | 268 | for _, strategy := range strategies { 269 | if err := strategy.Present(result); err != nil { 270 | return err 271 | } 272 | } 273 | 274 | return nil 275 | } 276 | 277 | // selectDiffStrategies picks the appropriate diff presentation implementations based on configuration. 278 | func (a *App) selectDiffStrategies(applicationFile string) ([]DiffStrategy, error) { 279 | var strategies []DiffStrategy 280 | 281 | if a.cfg.ExternalDiffTool != "" { 282 | strategies = append(strategies, ExternalDiffStrategy{ 283 | Log: a.logger, 284 | Tool: a.cfg.ExternalDiffTool, 285 | ShowAdded: a.cfg.PrintAddedManifests, 286 | ShowRemoved: a.cfg.PrintRemovedManifests, 287 | }) 288 | } else { 289 | strategies = append(strategies, StdoutStrategy{ 290 | Log: a.logger, 291 | ShowAdded: a.cfg.PrintAddedManifests, 292 | ShowRemoved: a.cfg.PrintRemovedManifests, 293 | }) 294 | } 295 | 296 | if a.cfg.Comment != nil && a.cfg.Comment.Provider != CommentProviderNone { 297 | poster, err := a.commentFactory(a.cfg) 298 | if err != nil { 299 | return nil, err 300 | } 301 | if poster == nil { 302 | return nil, fmt.Errorf("comment poster factory returned nil for provider %q", a.cfg.Comment.Provider) 303 | } 304 | 305 | strategies = append(strategies, CommentStrategy{ 306 | Log: a.logger, 307 | Poster: poster, 308 | ShowAdded: a.cfg.PrintAddedManifests, 309 | ShowRemoved: a.cfg.PrintRemovedManifests, 310 | ApplicationPath: applicationFile, 311 | }) 312 | } 313 | 314 | return strategies, nil 315 | } 316 | 317 | // collectRepoCredentials loads repository credentials from environment variables. 318 | func (a *App) collectRepoCredentials() error { 319 | a.logger.Debug("===> Collecting repo credentials") 320 | 321 | for _, env := range os.Environ() { 322 | if !strings.HasPrefix(env, repoCredsPrefix) { 323 | continue 324 | } 325 | 326 | var repoCreds models.RepoCredentials 327 | if err := json.Unmarshal([]byte(strings.SplitN(env, "=", 2)[1]), &repoCreds); err != nil { 328 | return err 329 | } 330 | a.repoCredentials = append(a.repoCredentials, repoCreds) 331 | } 332 | 333 | for _, repo := range a.repoCredentials { 334 | a.logger.Debugf("▶ Found repo credentials for [%s]", cyan(repo.Url)) 335 | } 336 | 337 | return nil 338 | } 339 | 340 | // reportInvalidFiles logs invalid manifests and returns an error when any are encountered. 341 | func (a *App) reportInvalidFiles(invalid []string) error { 342 | if len(invalid) == 0 { 343 | return nil 344 | } 345 | 346 | a.logger.Info("===> The following yaml files are invalid and were skipped") 347 | for _, file := range invalid { 348 | a.logger.Warningf("▶ %s", file) 349 | } 350 | 351 | return errors.New("invalid files found") 352 | } 353 | 354 | // defaultCommentPosterFactory returns a poster instance for the configured comment provider. 355 | // It expects cfg.Comment to be non-nil and already validated by the caller. 356 | func defaultCommentPosterFactory(cfg Config) (comment.Poster, error) { 357 | if cfg.Comment == nil { 358 | return nil, fmt.Errorf("comment factory requested with nil comment configuration") 359 | } 360 | if cfg.Comment.Provider == CommentProviderNone { 361 | return nil, fmt.Errorf("comment factory requested with comment provider %q", CommentProviderNone) 362 | } 363 | 364 | switch cfg.Comment.Provider { 365 | case CommentProviderGitLab: 366 | return gitlab.NewPoster(gitlab.Config{ 367 | BaseURL: cfg.Comment.GitLab.BaseURL, 368 | Token: cfg.Comment.GitLab.Token, 369 | ProjectID: cfg.Comment.GitLab.ProjectID, 370 | MergeRequestIID: cfg.Comment.GitLab.MergeRequestIID, 371 | }) 372 | default: 373 | return nil, fmt.Errorf("unsupported comment provider %q", cfg.Comment.Provider) 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /internal/app/comment_strategy.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/op/go-logging" 9 | "github.com/shini4i/argo-compare/internal/comment" 10 | ) 11 | 12 | // CommentStrategy delivers comparison results to an upstream comment system. 13 | type CommentStrategy struct { 14 | Log *logging.Logger 15 | Poster comment.Poster 16 | ShowAdded bool 17 | ShowRemoved bool 18 | ApplicationPath string 19 | } 20 | 21 | const ( 22 | // gitlabNoteLengthLimit reflects GitLab's documented 1 MB limit for note bodies. 23 | gitlabNoteLengthLimit = 1_000_000 24 | // commentPartReserve keeps room for part numbering suffixes when chunking comments. 25 | commentPartReserve = 32 26 | crdNoticeTemplate = "> CRD manifest `%s` detected in the %s section. Diff omitted to keep merge request comments concise. Review the job logs for full details.\n" 27 | ) 28 | 29 | // Present formats comparison results and pushes them as one or more comments depending on size. 30 | func (s CommentStrategy) Present(result ComparisonResult) error { 31 | if err := s.validate(); err != nil { 32 | return err 33 | } 34 | 35 | bodies := buildCommentBodies(result, s.ShowAdded, s.ShowRemoved, s.ApplicationPath) 36 | if err := s.postBodies(bodies); err != nil { 37 | return err 38 | } 39 | 40 | s.logResult(result, len(bodies)) 41 | return nil 42 | } 43 | 44 | func (s CommentStrategy) validate() error { 45 | if s.Poster == nil { 46 | return errors.New("comment strategy requires a poster implementation") 47 | } 48 | if s.Log == nil { 49 | return errors.New("comment strategy requires a logger") 50 | } 51 | return nil 52 | } 53 | 54 | func (s CommentStrategy) postBodies(bodies []string) error { 55 | for idx, body := range bodies { 56 | bodyToPost := body 57 | if len(bodies) > 1 { 58 | bodyToPost = ensureTrailingNewline(strings.TrimRight(body, "\n") + fmt.Sprintf("\n\n_Part %d of %d_", idx+1, len(bodies))) 59 | } 60 | if err := s.Poster.Post(bodyToPost); err != nil { 61 | if len(bodies) > 1 { 62 | return fmt.Errorf("post diff comment (part %d/%d): %w", idx+1, len(bodies), err) 63 | } 64 | return fmt.Errorf("post diff comment: %w", err) 65 | } 66 | } 67 | return nil 68 | } 69 | 70 | func (s CommentStrategy) logResult(result ComparisonResult, commentCount int) { 71 | app := strings.TrimSpace(s.ApplicationPath) 72 | if app == "" { 73 | app = "unknown application" 74 | } 75 | 76 | switch { 77 | case result.IsEmpty(): 78 | s.Log.Infof("Posted comment summarizing absence of manifest changes for %s", app) 79 | case commentCount > 1: 80 | s.Log.Infof("Posted %d comments with manifest diff summary for %s", commentCount, app) 81 | default: 82 | s.Log.Infof("Posted comment with manifest diff summary for %s", app) 83 | } 84 | } 85 | 86 | func buildCommentBodies(result ComparisonResult, showAdded, showRemoved bool, applicationPath string) []string { 87 | appLabel := strings.TrimSpace(applicationPath) 88 | if appLabel == "" { 89 | appLabel = "unknown" 90 | } 91 | appDisplay := strings.ReplaceAll(appLabel, "`", "\\`") 92 | 93 | var headerBuilder strings.Builder 94 | headerBuilder.WriteString("## Argo Compare Results\n\n") 95 | headerBuilder.WriteString(fmt.Sprintf("**Application:** `%s`\n\n", appDisplay)) 96 | 97 | if summary := buildSummaryLines(result, showAdded, showRemoved); summary != "" { 98 | headerBuilder.WriteString(summary) 99 | } 100 | 101 | header := headerBuilder.String() 102 | if result.IsEmpty() { 103 | return []string{ensureTrailingNewline(header + "No manifest differences detected :white_check_mark:\n")} 104 | } 105 | 106 | maxPerComment := computeMaxPerComment(len(header)) 107 | 108 | maxChunkLen := maxPerComment - len(header) 109 | if maxChunkLen <= 0 { 110 | maxChunkLen = maxPerComment / 2 111 | } 112 | 113 | chunks, notices := collectDiffChunks(result, showAdded, showRemoved, maxChunkLen) 114 | if len(notices) > 0 { 115 | var noticeBuilder strings.Builder 116 | noticeBuilder.WriteString("**CRD Notes**\n") 117 | for _, notice := range notices { 118 | noticeBuilder.WriteString(notice) 119 | if !strings.HasSuffix(notice, "\n") { 120 | noticeBuilder.WriteString("\n") 121 | } 122 | } 123 | noticeBuilder.WriteString("\n") 124 | chunks = append(chunks, noticeBuilder.String()) 125 | } 126 | 127 | return assembleCommentBodies(header, chunks) 128 | } 129 | 130 | func buildSummaryLines(result ComparisonResult, showAdded, showRemoved bool) string { 131 | var lines []string 132 | 133 | if showAdded || len(result.Added) > 0 { 134 | label := fmt.Sprintf("- Added: %d", len(result.Added)) 135 | if !showAdded && len(result.Added) > 0 { 136 | label += " (not shown)" 137 | } 138 | lines = append(lines, label) 139 | } 140 | 141 | if showRemoved || len(result.Removed) > 0 { 142 | label := fmt.Sprintf("- Removed: %d", len(result.Removed)) 143 | if !showRemoved && len(result.Removed) > 0 { 144 | label += " (not shown)" 145 | } 146 | lines = append(lines, label) 147 | } 148 | 149 | lines = append(lines, fmt.Sprintf("- Changed: %d", len(result.Changed))) 150 | 151 | if len(lines) == 0 { 152 | return "" 153 | } 154 | 155 | return "**Summary**\n" + strings.Join(lines, "\n") + "\n\n" 156 | } 157 | 158 | // collectDiffChunks flattens diff outputs into renderable chunks and gathers notices for omitted sections (e.g. CRDs). 159 | func collectDiffChunks(result ComparisonResult, showAdded, showRemoved bool, maxChunkLen int) ([]string, []string) { 160 | var ( 161 | chunks []string 162 | notices []string 163 | ) 164 | 165 | if showAdded { 166 | addedChunks, addedNotices := buildDiffChunks("Added", result.Added, maxChunkLen) 167 | chunks = append(chunks, addedChunks...) 168 | notices = append(notices, addedNotices...) 169 | } else if len(result.Added) > 0 { 170 | chunks = append(chunks, buildOmittedNotice("Added", len(result.Added))) 171 | } 172 | 173 | if showRemoved { 174 | removedChunks, removedNotices := buildDiffChunks("Removed", result.Removed, maxChunkLen) 175 | chunks = append(chunks, removedChunks...) 176 | notices = append(notices, removedNotices...) 177 | } else if len(result.Removed) > 0 { 178 | chunks = append(chunks, buildOmittedNotice("Removed", len(result.Removed))) 179 | } 180 | 181 | changedChunks, changedNotices := buildDiffChunks("Changed", result.Changed, maxChunkLen) 182 | chunks = append(chunks, changedChunks...) 183 | notices = append(notices, changedNotices...) 184 | 185 | return chunks, notices 186 | } 187 | 188 | func buildOmittedNotice(section string, count int) string { 189 | return fmt.Sprintf("> %s manifests (%d) are present but not shown with the current settings.\n\n", section, count) 190 | } 191 | 192 | // buildDiffChunks produces diff chunks for a single section (Added/Removed/Changed) and returns any notices. 193 | func buildDiffChunks(section string, entries []DiffOutput, maxChunkLen int) ([]string, []string) { 194 | var ( 195 | chunks []string 196 | notices []string 197 | ) 198 | for _, entry := range entries { 199 | entryChunks, notice := buildDiffEntryChunks(section, entry, maxChunkLen) 200 | if notice != "" { 201 | notices = append(notices, notice) 202 | } 203 | chunks = append(chunks, entryChunks...) 204 | } 205 | return chunks, notices 206 | } 207 | 208 | // buildDiffEntryChunks formats a single diff entry into one or more chunks, returning the diff text and optional notice. 209 | func buildDiffEntryChunks(section string, entry DiffOutput, maxChunkLen int) ([]string, string) { 210 | fileName := strings.TrimPrefix(entry.File.Name, "/") 211 | if fileName == "" { 212 | fileName = "unknown" 213 | } 214 | 215 | diff := strings.TrimRight(entry.Diff, "\n") 216 | if diff == "" { 217 | diff = "(no diff output)" 218 | } 219 | 220 | if isCRDManifest(entry) { 221 | notice := fmt.Sprintf(crdNoticeTemplate, fileName, strings.ToLower(section)) 222 | return nil, notice 223 | } 224 | 225 | diff = stripDiffHeaders(diff) 226 | 227 | closing := "\n```\n\n\n" 228 | var chunks []string 229 | part := 1 230 | remaining := diff 231 | 232 | for len(remaining) > 0 { 233 | summaryLabel := fmt.Sprintf("%s • %s", section, fileName) 234 | if part > 1 { 235 | summaryLabel = fmt.Sprintf("%s • %s (part %d)", section, fileName, part) 236 | } 237 | 238 | opening := fmt.Sprintf("
\n%s\n\n```diff\n", summaryLabel) 239 | available := maxChunkLen - len(opening) - len(closing) 240 | if available < 1 { 241 | available = 1 242 | } 243 | 244 | chunkDiff, rest := splitDiffContent(remaining, available) 245 | 246 | var builder strings.Builder 247 | builder.WriteString(opening) 248 | builder.WriteString(chunkDiff) 249 | if !strings.HasSuffix(chunkDiff, "\n") { 250 | builder.WriteString("\n") 251 | } 252 | builder.WriteString(closing) 253 | 254 | chunks = append(chunks, builder.String()) 255 | remaining = rest 256 | part++ 257 | } 258 | 259 | return chunks, "" 260 | } 261 | 262 | func splitDiffContent(content string, limit int) (string, string) { 263 | if limit <= 0 || len(content) <= limit { 264 | return content, "" 265 | } 266 | 267 | cut := strings.LastIndex(content[:limit], "\n") 268 | if cut <= 0 { 269 | cut = limit 270 | } 271 | 272 | chunk := content[:cut] 273 | remaining := content[cut:] 274 | return chunk, strings.TrimPrefix(remaining, "\n") 275 | } 276 | 277 | func assembleCommentBodies(header string, chunks []string) []string { 278 | maxPerComment := computeMaxPerComment(len(header)) 279 | 280 | var bodies []string 281 | var builder strings.Builder 282 | builder.WriteString(header) 283 | 284 | for _, chunk := range includeNonEmptyChunks(chunks) { 285 | if builder.Len()+len(chunk) > maxPerComment { 286 | if builder.Len() > len(header) { 287 | bodies = append(bodies, ensureTrailingNewline(builder.String())) 288 | builder.Reset() 289 | builder.WriteString(header) 290 | } 291 | } 292 | 293 | builder.WriteString(chunk) 294 | } 295 | 296 | if builder.Len() > len(header) { 297 | bodies = append(bodies, ensureTrailingNewline(builder.String())) 298 | } 299 | 300 | if len(bodies) == 0 { 301 | bodies = append(bodies, ensureTrailingNewline(header)) 302 | } 303 | 304 | return bodies 305 | } 306 | 307 | func includeNonEmptyChunks(chunks []string) []string { 308 | result := make([]string, 0, len(chunks)) 309 | for _, chunk := range chunks { 310 | if strings.TrimSpace(chunk) == "" { 311 | continue 312 | } 313 | result = append(result, chunk) 314 | } 315 | return result 316 | } 317 | 318 | func ensureTrailingNewline(body string) string { 319 | body = strings.TrimRight(body, "\n") + "\n" 320 | return body 321 | } 322 | 323 | func computeMaxPerComment(headerLen int) int { 324 | maxPerComment := gitlabNoteLengthLimit - commentPartReserve 325 | if maxPerComment <= 0 { 326 | maxPerComment = gitlabNoteLengthLimit 327 | } 328 | if headerLen >= maxPerComment { 329 | maxPerComment = headerLen + 1 330 | } 331 | return maxPerComment 332 | } 333 | 334 | // stripDiffHeaders removes git metadata headers from diff output, leaving only the hunk details. 335 | func stripDiffHeaders(diff string) string { 336 | lines := strings.Split(diff, "\n") 337 | start := 0 338 | for start < len(lines) { 339 | line := lines[start] 340 | if strings.HasPrefix(line, "diff --git ") || 341 | strings.HasPrefix(line, "index ") || 342 | strings.HasPrefix(line, "--- ") || 343 | strings.HasPrefix(line, "+++ ") { 344 | start++ 345 | continue 346 | } 347 | break 348 | } 349 | 350 | if start >= len(lines) { 351 | return "" 352 | } 353 | return strings.Join(lines[start:], "\n") 354 | } 355 | 356 | // isCRDManifest reports whether the provided diff output appears to describe a 357 | // CustomResourceDefinition manifest by inspecting both the path and diff 358 | // content. 359 | func isCRDManifest(entry DiffOutput) bool { 360 | name := strings.ToLower(strings.Trim(entry.File.Name, "/")) 361 | if hasCRDPathIndicator(name) { 362 | return true 363 | } 364 | 365 | diffLower := strings.ToLower(entry.Diff) 366 | return strings.Contains(diffLower, "kind: customresourcedefinition") 367 | } 368 | 369 | // hasCRDPathIndicator reports whether the path strongly suggests a CRD manifest. 370 | func hasCRDPathIndicator(name string) bool { 371 | if name == "" { 372 | return false 373 | } 374 | 375 | segments := strings.Split(name, "/") 376 | for idx, segment := range segments { 377 | if segment == "" { 378 | continue 379 | } 380 | if segment == "crd" || segment == "crds" { 381 | return true 382 | } 383 | isLastSegment := idx == len(segments)-1 384 | if isLastSegment && hasCRDFilenamePattern(segment) { 385 | return true 386 | } 387 | } 388 | 389 | return false 390 | } 391 | 392 | // hasCRDFilenamePattern reports whether a file name follows common CRD manifest conventions. 393 | func hasCRDFilenamePattern(segment string) bool { 394 | if segment == "" { 395 | return false 396 | } 397 | 398 | lowered := strings.ToLower(segment) 399 | if strings.Contains(lowered, ".crd.") { 400 | return true 401 | } 402 | 403 | crdSuffixes := []string{ 404 | "crd.yaml", 405 | "crd.yml", 406 | "-crd.yaml", 407 | "-crd.yml", 408 | "_crd.yaml", 409 | "_crd.yml", 410 | } 411 | 412 | for _, suffix := range crdSuffixes { 413 | if strings.HasSuffix(lowered, suffix) { 414 | return true 415 | } 416 | } 417 | 418 | return false 419 | } 420 | --------------------------------------------------------------------------------