├── .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 | 
8 | 
9 | 
10 | [](https://codecov.io/gh/shini4i/argo-compare)
11 | [](https://goreportcard.com/report/github.com/shini4i/argo-compare)
12 | 
13 |
14 |

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 |
--------------------------------------------------------------------------------