├── .github
├── dependabot.yml
└── workflows
│ ├── release.yml
│ └── run-tests.yml
├── .gitignore
├── .golangci.yml
├── .goreleaser.yaml
├── .pre-commit-config.yaml
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── cmd
└── argo-compare
│ ├── cli.go
│ ├── cli_test.go
│ ├── compare.go
│ ├── compare_test.go
│ ├── git.go
│ ├── git_test.go
│ ├── interfaces
│ └── interfaces.go
│ ├── main.go
│ ├── main_test.go
│ ├── target.go
│ ├── target_test.go
│ └── utils
│ ├── cmdrunner.go
│ ├── cmdrunner_test.go
│ ├── file-reader.go
│ ├── file-reader_test.go
│ ├── globber.go
│ ├── globber_test.go
│ ├── helm-chart-processor.go
│ ├── helm-chart-processor_test.go
│ ├── osfs.go
│ └── osfs_test.go
├── codecov.yml
├── go.mod
├── go.sum
├── internal
├── helpers
│ ├── helpers.go
│ └── helpers_test.go
└── models
│ ├── application.go
│ ├── application_test.go
│ └── repo.go
├── patch
└── diff-so-fancy.patch
└── testdata
├── disposable
└── .gitkeep
├── dynamic
└── deployment.yaml
├── repo.git
├── HEAD
├── config
├── description
├── hooks
│ ├── applypatch-msg.sample
│ ├── commit-msg.sample
│ ├── fsmonitor-watchman.sample
│ ├── post-update.sample
│ ├── pre-applypatch.sample
│ ├── pre-commit.sample
│ ├── pre-merge-commit.sample
│ ├── pre-push.sample
│ ├── pre-rebase.sample
│ ├── pre-receive.sample
│ ├── prepare-commit-msg.sample
│ ├── push-to-checkout.sample
│ └── update.sample
├── info
│ └── exclude
├── objects
│ ├── 14
│ │ └── 4ee8cf8a2bc327c05e5478f46c8b4d867c2d53
│ ├── 41
│ │ └── 334bbafb63ec09623eb5e970ef98d39bb3840f
│ ├── 42
│ │ └── fa0288a96d4c0c0ed530f982f13b31fde03da6
│ ├── 61
│ │ └── 95e9b8bde1e012ccf756216fa225d35fa33690
│ ├── 63
│ │ └── 988c89a49d0b3a2afa0764ef4ea8e76d501b46
│ ├── 93
│ │ └── 9b376ec8b6af2ba467bd0ca7d57499159b3ba3
│ ├── 5e
│ │ └── c56f7d7663cfd5f0b5b85dfdd46ef5addf9fe0
│ ├── 8f
│ │ └── c4774c9313d27406df5e3ff71fa4ec68c0deb7
│ ├── b8
│ │ └── 5e290803976b2a52702a0a699706f6e4c4ed86
│ ├── bd
│ │ └── a469dd11cdfa8cc28bc7e044c360737327e3ce
│ └── fc
│ │ └── a984329cd120e7b7fe648cb534a2add7291d41
└── packed-refs
├── test-values.yaml
├── test.yaml
└── test2.yaml
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gomod"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # IDE files
2 | .idea
3 |
4 | # build artifacts
5 | bin/
6 | dist/
7 |
8 | # local env files
9 | .env
10 | .envrc
11 |
12 | # coverage files
13 | coverage.out
14 | coverage.html
15 | report.xml
16 |
17 | # generated mocks
18 | cmd/argo-compare/mocks/
19 |
20 | # files generated by tests
21 | testdata/disposable
22 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | issues:
2 | max-per-linter: 0
3 | max-same-issues: 0
4 |
5 | linters:
6 | disable-all: true
7 | enable:
8 | - durationcheck
9 | - errcheck
10 | - exportloopref
11 | - forcetypeassert
12 | - godot
13 | - gofmt
14 | - gosimple
15 | - ineffassign
16 | - makezero
17 | - misspell
18 | - nilerr
19 | - predeclared
20 | - staticcheck
21 | - tenv
22 | - unconvert
23 | - unparam
24 | - unused
25 | - vet
26 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 | - id: golangci-lint
25 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:3.20 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.20
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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=cmd/argo-compare/interfaces/interfaces.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 testdata/repo.git/refs/heads testdata/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 |
--------------------------------------------------------------------------------
/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 | #### Password Protected Repositories
67 | Using password protected repositories is a bit more challenging. To make it work, we need to expose JSON as an environment variable.
68 | The JSON should contain the following fields:
69 |
70 | ```json
71 | {
72 | "url": "https://charts.example.com",
73 | "username": "username",
74 | "password": "password"
75 | }
76 | ```
77 | How to properly expose it depends on the specific use case.
78 |
79 | A bash example:
80 | ```bash
81 | export REPO_CREDS_EXAMPLE={\"url\":\"https://charts.example.com\",\"username\":\"username\",\"password\":\"password\"}
82 | ```
83 |
84 | Where `EXAMPLE` is an identifier that is not used by the application.
85 |
86 | Argo Compare will look for all `REPO_CREDS_*` environment variables and use them if `url` will match the `repoURL` from Application manifest.
87 |
88 |
89 | ## How it works
90 |
91 | 1) First, this tool will check which files are changed compared to the files in the target branch.
92 | 2) It will get the content of the changed Application files from the target branch.
93 | 3) It will render manifests using the helm template using source and target branch values.
94 | 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)
95 | 5) As the last step, it will compare rendered manifest from the source and destination branches and print the
96 | difference.
97 |
98 | ## Current limitations
99 |
100 | - Works only with Applications that are using helm repositories and helm values present in the Application yaml.
101 | - Does not support password protected repositories.
102 |
103 | ## Roadmap
104 |
105 | - [ ] Add support for Application using git as a source of helm chart
106 | - [x] Add support for providing credentials for password protected helm repositories
107 | - [ ] Add support for posting diff as a comment to PR (GitHub)/MR(GitLab)
108 |
109 | ## Contributing
110 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
111 |
--------------------------------------------------------------------------------
/cmd/argo-compare/cli.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/alecthomas/kong"
8 | )
9 |
10 | var CLI struct {
11 | Debug bool `help:"Enable debug mode" short:"d"`
12 | DropCache DropCache `help:"Drop cache directory"`
13 | Version kong.VersionFlag `help:"Show version" short:"v"`
14 |
15 | Branch struct {
16 | Name string `arg:"" type:"string"`
17 | File string `help:"Compare a single file" short:"f"`
18 | Ignore []string `help:"Ignore a specific file" short:"i"`
19 | PreserveHelmLabels bool `help:"Preserve Helm labels during comparison"`
20 | PrintAddedManifests bool `help:"Print added manifests"`
21 | PrintRemovedManifests bool `help:"Print removed manifests"`
22 | FullOutput bool `help:"Print full output"`
23 | } `cmd:"" help:"target branch to compare with" type:"string"`
24 | }
25 |
26 | var (
27 | targetBranch string
28 | fileToCompare string
29 | filesToIgnore []string
30 | preserveHelmLabels bool
31 | printAddedManifests bool
32 | printRemovedManifests bool
33 | )
34 |
35 | type DropCache bool
36 |
37 | func (d *DropCache) BeforeApply(app *kong.Kong) error {
38 | fmt.Printf("===> Purging cache directory: %s\n", cacheDir)
39 |
40 | if err := os.RemoveAll(cacheDir); err != nil {
41 | return err
42 | }
43 |
44 | // it is required to be able to unit test this function
45 | if app != nil {
46 | app.Exit(0)
47 | }
48 |
49 | return nil
50 | }
51 |
--------------------------------------------------------------------------------
/cmd/argo-compare/cli_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestDropCacheBeforeApply(t *testing.T) {
12 | t.Run("cacheDir successfully removed", func(t *testing.T) {
13 | tempDir, err := os.MkdirTemp(testsDir, "test-")
14 | if err != nil {
15 | t.Fatal(err)
16 | }
17 | defer func(path string) {
18 | err := os.RemoveAll(path)
19 | if err != nil {
20 | t.Fatal(err)
21 | }
22 | }(tempDir) // clean up in case of failure
23 |
24 | cacheDir = tempDir
25 |
26 | d := DropCache(true)
27 | err = d.BeforeApply(nil)
28 | assert.Nil(t, err)
29 |
30 | // check if cacheDir does not exist anymore
31 | _, err = os.Stat(cacheDir)
32 | assert.True(t, os.IsNotExist(err))
33 | })
34 |
35 | t.Run("cacheDir removal fails", func(t *testing.T) {
36 | // Test case 2: cacheDir removal fails
37 | tmpDir, err := os.MkdirTemp("", "test-failure-")
38 | if err != nil {
39 | t.Fatal(err)
40 | }
41 | defer func(path string) {
42 | if err := os.Chmod(tmpDir, 0700); err != nil {
43 | t.Fatal(err)
44 | }
45 | if err := os.RemoveAll(path); err != nil {
46 | t.Fatal(err)
47 | }
48 | }(tmpDir)
49 |
50 | // Create a read-only file inside the directory
51 | tmpFile, err := os.Create(filepath.Join(tmpDir, "readonlyfile.txt"))
52 | if err != nil {
53 | t.Fatalf("Failed to create temporary file: %v", err)
54 | }
55 |
56 | if err := tmpFile.Close(); err != nil {
57 | t.Fatalf("Failed to close temporary file: %v", err)
58 | }
59 |
60 | // Make the directory unwritable
61 | err = os.Chmod(tmpDir, 0400)
62 | if err != nil {
63 | t.Fatalf("Failed to change permissions: %v", err)
64 | }
65 |
66 | cacheDir = tmpDir
67 |
68 | d := DropCache(true)
69 |
70 | err = d.BeforeApply(nil)
71 | assert.Error(t, err)
72 | })
73 | }
74 |
--------------------------------------------------------------------------------
/cmd/argo-compare/compare.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os/exec"
6 | "path/filepath"
7 | "reflect"
8 | "strings"
9 | "sync"
10 |
11 | "github.com/codingsince1985/checksum"
12 | "github.com/hexops/gotextdiff"
13 | "github.com/hexops/gotextdiff/myers"
14 | "github.com/hexops/gotextdiff/span"
15 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces"
16 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils"
17 | "github.com/shini4i/argo-compare/internal/helpers"
18 | "github.com/spf13/afero"
19 | )
20 |
21 | type File struct {
22 | Name string
23 | Sha string
24 | }
25 |
26 | type Compare struct {
27 | CmdRunner interfaces.CmdRunner
28 | Globber utils.CustomGlobber
29 | externalDiffTool string
30 | srcFiles []File
31 | dstFiles []File
32 | diffFiles []File
33 | addedFiles []File
34 | removedFiles []File
35 | }
36 |
37 | const (
38 | srcPathPattern = "%s/templates/src/%s"
39 | dstPathPattern = "%s/templates/dst/%s"
40 | currentFilePrintPattern = "▶ %s"
41 | )
42 |
43 | func (c *Compare) findFiles() {
44 | // Most of the time, we want to avoid huge output containing helm labels update only,
45 | // but we still want to be able to see the diff if needed
46 | if !preserveHelmLabels {
47 | c.findAndStripHelmLabels()
48 | }
49 |
50 | wg := new(sync.WaitGroup)
51 | wg.Add(2)
52 |
53 | go func() {
54 | defer wg.Done()
55 | globPattern := filepath.Join(filepath.Join(filepath.Join(tmpDir, "templates/src"), "**", "*.yaml"))
56 | srcFiles, err := c.Globber.Glob(globPattern)
57 | if err != nil {
58 | log.Fatal(err)
59 | }
60 | c.srcFiles = c.processFiles(srcFiles, "src")
61 | }()
62 |
63 | go func() {
64 | defer wg.Done()
65 | globPattern := filepath.Join(filepath.Join(filepath.Join(tmpDir, "templates/dst"), "**", "*.yaml"))
66 | if dstFiles, err := c.Globber.Glob(globPattern); err != nil {
67 | // we are no longer failing here, because we need to support the case where the destination
68 | // branch does not have the Application yet
69 | log.Debugf("Error while finding files in %s: %s", filepath.Join(tmpDir, "templates/dst"), err)
70 | } else {
71 | c.dstFiles = c.processFiles(dstFiles, "dst")
72 | }
73 | }()
74 |
75 | wg.Wait()
76 |
77 | if !reflect.DeepEqual(c.srcFiles, c.dstFiles) {
78 | c.generateFilesStatus()
79 | }
80 | }
81 |
82 | func (c *Compare) processFiles(files []string, filesType string) []File {
83 | var processedFiles []File
84 |
85 | path := filepath.Join(tmpDir, "templates", filesType)
86 |
87 | for _, file := range files {
88 | if sha256sum, err := checksum.SHA256sum(file); err != nil {
89 | log.Fatal(err)
90 | } else {
91 | processedFiles = append(processedFiles, File{Name: strings.TrimPrefix(file, path), Sha: sha256sum})
92 | }
93 | }
94 |
95 | return processedFiles
96 | }
97 |
98 | func (c *Compare) generateFilesStatus() {
99 | c.findNewOrRemovedFiles()
100 | c.compareFiles()
101 | }
102 |
103 | func (c *Compare) compareFiles() {
104 | var diffFiles []File
105 |
106 | for _, srcFile := range c.srcFiles {
107 | for _, dstFile := range c.dstFiles {
108 | if srcFile.Name == dstFile.Name && srcFile.Sha != dstFile.Sha {
109 | diffFiles = append(diffFiles, srcFile)
110 | }
111 | }
112 | }
113 |
114 | c.diffFiles = diffFiles
115 | }
116 |
117 | // findNewOrRemovedFiles scans source and destination files to identify
118 | // newly added or removed files. It populates the addedFiles and removedFiles
119 | // fields of the Compare struct with the respective files. A file is considered
120 | // added if it exists in the source but not in the destination, and removed if
121 | // it exists in the destination but not in the source.
122 | func (c *Compare) findNewOrRemovedFiles() {
123 | srcFileMap := make(map[string]File)
124 | for _, srcFile := range c.srcFiles {
125 | srcFileMap[srcFile.Name] = srcFile
126 | }
127 |
128 | dstFileMap := make(map[string]File)
129 | for _, dstFile := range c.dstFiles {
130 | dstFileMap[dstFile.Name] = dstFile
131 | }
132 |
133 | for fileName, srcFile := range srcFileMap {
134 | if _, found := dstFileMap[fileName]; !found {
135 | c.addedFiles = append(c.addedFiles, srcFile)
136 | }
137 | }
138 |
139 | for fileName, dstFile := range dstFileMap {
140 | if _, found := srcFileMap[fileName]; !found {
141 | c.removedFiles = append(c.removedFiles, dstFile)
142 | }
143 | }
144 | }
145 |
146 | // printFilesStatus logs the status of the files processed during comparison.
147 | // It determines whether files have been added, removed or have differences,
148 | // and calls printFiles for each case.
149 | func (c *Compare) printFilesStatus() {
150 | if len(c.addedFiles) == 0 && len(c.removedFiles) == 0 && len(c.diffFiles) == 0 {
151 | log.Info("No diff was found in rendered manifests!")
152 | return
153 | }
154 |
155 | c.printFiles(c.addedFiles, "added")
156 | c.printFiles(c.removedFiles, "removed")
157 | c.printFiles(c.diffFiles, "changed")
158 | }
159 |
160 | // printFiles logs the files that are subject to an operation (addition, removal, change).
161 | // It logs the number of affected files and processes each file according to the operation.
162 | func (c *Compare) printFiles(files []File, operation string) {
163 | if len(files) > 0 {
164 | fileText := "file"
165 | if len(files) > 1 {
166 | fileText = "files"
167 | }
168 | log.Infof("The following %d %s would be %s:", len(files), fileText, operation)
169 | for _, file := range files {
170 | log.Infof(currentFilePrintPattern, file.Name)
171 | c.printDiffFile(file)
172 | }
173 | }
174 | }
175 |
176 | func (c *Compare) printDiffFile(diffFile File) {
177 | dstFilePath := fmt.Sprintf(dstPathPattern, tmpDir, diffFile.Name)
178 | srcFilePath := fmt.Sprintf(srcPathPattern, tmpDir, diffFile.Name)
179 |
180 | srcFile := string(ReadFile(srcFilePath))
181 | dstFile := string(ReadFile(dstFilePath))
182 |
183 | edits := myers.ComputeEdits(span.URIFromPath(srcFilePath), dstFile, srcFile)
184 |
185 | output := fmt.Sprint(gotextdiff.ToUnified(srcFilePath, dstFilePath, dstFile, edits))
186 |
187 | if c.externalDiffTool != "" {
188 | cmd := exec.Command(c.externalDiffTool) // #nosec G204
189 |
190 | // Set the external program's stdin to read from a pipe
191 | cmd.Stdin = strings.NewReader(output)
192 |
193 | // Capture the output of the external program
194 | cmdOutput, err := cmd.CombinedOutput()
195 | if err != nil {
196 | fmt.Println("Error running external program:", err)
197 | }
198 |
199 | // Print the external program's output
200 | fmt.Println(string(cmdOutput))
201 | } else {
202 | fmt.Println(output)
203 | }
204 | }
205 |
206 | // findAndStripHelmLabels scans directory for YAML files, strips pre-defined Helm labels,
207 | // and writes modified content back.
208 | func (c *Compare) findAndStripHelmLabels() {
209 | var helmFiles []string
210 | var err error
211 |
212 | if helmFiles, err = c.Globber.Glob(filepath.Join(tmpDir, "**", "*.yaml")); err != nil {
213 | log.Fatal(err)
214 | }
215 |
216 | for _, helmFile := range helmFiles {
217 | if desiredState, err := helpers.StripHelmLabels(helmFile); err != nil {
218 | log.Fatal(err)
219 | } else {
220 | if err := helpers.WriteToFile(afero.NewOsFs(), helmFile, desiredState); err != nil {
221 | log.Fatal(err)
222 | }
223 | }
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/cmd/argo-compare/compare_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "os"
6 | "testing"
7 |
8 | "github.com/op/go-logging"
9 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils"
10 | "github.com/stretchr/testify/assert"
11 | )
12 |
13 | const (
14 | file1 = "file1.txt"
15 | file2 = "file2.txt"
16 | file3 = "file3.txt"
17 | file4 = "file4.txt"
18 | )
19 |
20 | func TestGenerateFilesStatus(t *testing.T) {
21 | srcFiles := []File{
22 | {Name: file1, Sha: "1234"},
23 | {Name: file3, Sha: "3456"},
24 | {Name: file4, Sha: "7890"},
25 | }
26 |
27 | dstFiles := []File{
28 | {Name: file1, Sha: "5678"},
29 | {Name: file2, Sha: "9012"},
30 | {Name: file3, Sha: "3456"},
31 | }
32 |
33 | c := Compare{
34 | srcFiles: srcFiles,
35 | dstFiles: dstFiles,
36 | }
37 |
38 | expectedAddedFiles := []File{{Name: file4, Sha: "7890"}}
39 | expectedRemovedFiles := []File{{Name: file2, Sha: "9012"}}
40 | expectedDiffFiles := []File{{Name: file1, Sha: "1234"}}
41 |
42 | c.generateFilesStatus()
43 |
44 | assert.Equal(t, expectedAddedFiles, c.addedFiles)
45 | assert.Equal(t, expectedRemovedFiles, c.removedFiles)
46 | assert.Equal(t, expectedDiffFiles, c.diffFiles)
47 | }
48 |
49 | func TestFindAndStripHelmLabels(t *testing.T) {
50 | // Prepare test data
51 | testFile := "../../testdata/dynamic/deployment.yaml"
52 | backupFile := testFile + ".bak"
53 |
54 | // Read the original file content
55 | originalData, err := os.ReadFile(testFile)
56 | if err != nil {
57 | t.Fatalf("Failed to read the original file: %s", err)
58 | }
59 |
60 | // Create a backup of the original file
61 | if err := os.WriteFile(backupFile, originalData, 0644); err != nil {
62 | t.Fatalf("Failed to create a backup of the test file: %s", err)
63 | }
64 | defer func() {
65 | // Restore the original file
66 | err := os.Rename(backupFile, testFile)
67 | if err != nil {
68 | t.Fatalf("Failed to restore the test file: %s", err)
69 | }
70 | }()
71 |
72 | // Change directory to the testdata directory
73 | if err := os.Chdir("../../testdata/dynamic"); err != nil {
74 | t.Fatalf("Failed to change directory: %s", err)
75 | }
76 |
77 | // Create an instance of Compare
78 | c := &Compare{}
79 |
80 | // Call the method to find and strip Helm labels
81 | c.findAndStripHelmLabels()
82 |
83 | // Return to the original directory
84 | if err := os.Chdir("../../cmd/argo-compare"); err != nil {
85 | t.Fatalf("Failed to change directory: %s", err)
86 | }
87 |
88 | // Read the modified file
89 | modifiedData, err := os.ReadFile(testFile)
90 | assert.NoError(t, err)
91 |
92 | // Define the expected modified content
93 | expectedOutput := `# for testing purpose we need only limited fields
94 | apiVersion: apps/v1
95 | kind: Deployment
96 | metadata:
97 | labels:
98 | app.kubernetes.io/instance: traefik-web
99 | app.kubernetes.io/name: traefik
100 | argocd.argoproj.io/instance: traefik
101 | name: traefik
102 | namespace: web
103 | `
104 |
105 | // Compare the modified output with the expected output
106 | assert.Equal(t, expectedOutput, string(modifiedData))
107 | }
108 |
109 | func TestProcessFiles(t *testing.T) {
110 | compare := &Compare{
111 | CmdRunner: &utils.RealCmdRunner{},
112 | }
113 |
114 | files := []string{
115 | "../../testdata/test.yaml",
116 | "../../testdata/test-values.yaml",
117 | }
118 |
119 | expectedFiles := []File{
120 | {
121 | Name: "../../testdata/test.yaml",
122 | Sha: "e263e4264f5570000b3666d6d07749fb67d4b82a6a1e1c1736503adcb7942e5b",
123 | },
124 | {
125 | Name: "../../testdata/test-values.yaml",
126 | Sha: "c22e6d877e8c49693306eb2d16affaa3a318fe602f36b6e733428e9c16ebfa32",
127 | },
128 | }
129 |
130 | foundFiles := compare.processFiles(files, "src")
131 |
132 | assert.Equal(t, expectedFiles, foundFiles)
133 | }
134 |
135 | func TestPrintFilesStatus(t *testing.T) {
136 | // Test case 1: No added, removed or changed files
137 | var buf bytes.Buffer
138 | backend := logging.NewLogBackend(&buf, "", 0)
139 | logging.SetBackend(backend)
140 |
141 | c := &Compare{
142 | CmdRunner: &utils.RealCmdRunner{},
143 | }
144 | c.printFilesStatus()
145 |
146 | logs := buf.String()
147 | assert.Contains(t, logs, "No diff was found in rendered manifests!")
148 |
149 | // Test case 2: Found added and removed files, but no changed files
150 | backend = logging.NewLogBackend(&buf, "", 0)
151 |
152 | logging.SetBackend(backend)
153 |
154 | c = &Compare{
155 | addedFiles: []File{{Name: "file1", Sha: "123"}},
156 | removedFiles: []File{{Name: "file2", Sha: "456"}, {Name: "file3", Sha: "789"}},
157 | diffFiles: []File{},
158 | }
159 |
160 | c.printFilesStatus()
161 |
162 | logs = buf.String()
163 | assert.Contains(t, logs, "The following 1 file would be added:")
164 | assert.Contains(t, logs, "The following 2 files would be removed:")
165 | assert.NotContains(t, logs, "The following 1 file would be changed:")
166 | }
167 |
--------------------------------------------------------------------------------
/cmd/argo-compare/git.go:
--------------------------------------------------------------------------------
1 | package main
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 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces"
13 | "github.com/shini4i/argo-compare/internal/helpers"
14 | "github.com/spf13/afero"
15 |
16 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils"
17 | "github.com/shini4i/argo-compare/internal/models"
18 | )
19 |
20 | type GitRepo struct {
21 | Repo *git.Repository
22 | CmdRunner interfaces.CmdRunner
23 | FsType afero.Fs
24 | changedFiles []string
25 | invalidFiles []string
26 | }
27 |
28 | var (
29 | gitFileDoesNotExist = errors.New("file does not exist in target branch")
30 | )
31 |
32 | // printChangeFile logs the names of the changed files found in the provided slice
33 | // if they are not empty strings, prefixed with a debug level message.
34 | func printChangeFile(addedFiles, Removed []string) {
35 | log.Debug("===> Found the following changed files:")
36 | for _, file := range addedFiles {
37 | if file != "" {
38 | log.Debugf("▶ %s", file)
39 | }
40 | }
41 | log.Debug("===> Found the following removed files:")
42 | for _, file := range Removed {
43 | if file != "" {
44 | log.Debugf("▶ %s", red(file))
45 | }
46 | }
47 | }
48 |
49 | // sortChangedFiles inspects each provided .yaml file and classifies them as an application,
50 | // invalid or otherwise. Non-application and unsupported files are logged and skipped, while
51 | // valid application files are added to 'changedFiles' for further processing.
52 | func (g *GitRepo) sortChangedFiles(fileReader interfaces.FileReader, files []string) {
53 | for _, file := range files {
54 | if filepath.Ext(file) == ".yaml" {
55 | switch isApp, err := checkIfApp(g.CmdRunner, fileReader, file); {
56 | case errors.Is(err, models.NotApplicationError):
57 | log.Debugf("Skipping non-application file [%s]", file)
58 | case errors.Is(err, models.UnsupportedAppConfigurationError):
59 | log.Warningf("Skipping unsupported application configuration [%s]", file)
60 | case errors.Is(err, models.EmptyFileError):
61 | log.Debugf("Skipping empty file [%s]", file)
62 | case err != nil:
63 | log.Errorf("Error checking if [%s] is an Application: %s", file, err)
64 | g.invalidFiles = append(g.invalidFiles, file)
65 | case isApp:
66 | g.changedFiles = append(g.changedFiles, file)
67 | }
68 | }
69 | }
70 |
71 | if len(g.changedFiles) > 0 {
72 | log.Info("===> Found the following changed Application files")
73 | for _, file := range g.changedFiles {
74 | log.Infof("▶ %s", yellow(file))
75 | }
76 | }
77 | }
78 |
79 | // getChangedFiles returns a list of files changed between the target branch and current HEAD.
80 | // It retrieves commit objects for the target and current branch, calculates the diff between trees and collects changed files.
81 | // If errors occur during these steps, they are returned.
82 | // It also triggers the logging and sorting processes for the changed files.
83 | func (g *GitRepo) getChangedFiles(fileReader interfaces.FileReader) ([]string, error) {
84 | targetRef, err := g.Repo.Reference(plumbing.ReferenceName(fmt.Sprintf("refs/remotes/origin/%s", targetBranch)), true)
85 | if err != nil {
86 | return nil, fmt.Errorf("failed to resolve target branch %s: %v", targetBranch, err)
87 | }
88 |
89 | // Retrieve the current branch reference.
90 | headRef, err := g.Repo.Head()
91 | if err != nil {
92 | return nil, fmt.Errorf("failed to get HEAD: %v", err)
93 | }
94 |
95 | // Get the commit objects for the target branch and the current branch.
96 | targetCommit, err := g.Repo.CommitObject(targetRef.Hash())
97 | if err != nil {
98 | return nil, fmt.Errorf("failed to get commit object for target branch %s: %v", targetBranch, err)
99 | }
100 |
101 | headCommit, err := g.Repo.CommitObject(headRef.Hash())
102 | if err != nil {
103 | return nil, fmt.Errorf("failed to get commit object for current branch: %v", err)
104 | }
105 |
106 | // Get the tree objects for the target commit and the current commit.
107 | targetTree, err := targetCommit.Tree()
108 | if err != nil {
109 | return nil, fmt.Errorf("failed to get tree for target commit: %v", err)
110 | }
111 |
112 | headTree, err := headCommit.Tree()
113 | if err != nil {
114 | return nil, fmt.Errorf("failed to get tree for head commit: %v", err)
115 | }
116 |
117 | // Get the diff between the two trees.
118 | changes, err := object.DiffTree(targetTree, headTree)
119 | if err != nil {
120 | return nil, fmt.Errorf("failed to get diff between trees: %v", err)
121 | }
122 |
123 | // Collect all the changed files
124 | var foundFiles, removedFiles []string
125 | for _, change := range changes {
126 | if change.To.Name == "" {
127 | removedFiles = append(removedFiles, change.From.Name)
128 | continue
129 | }
130 | foundFiles = append(foundFiles, change.To.Name)
131 | }
132 |
133 | printChangeFile(foundFiles, removedFiles)
134 | g.sortChangedFiles(fileReader, foundFiles)
135 |
136 | return g.changedFiles, nil
137 | }
138 |
139 | // getChangedFileContent retrieves the content of a given targetFile in the provided targetBranch.
140 | // It retrieves the branch reference, commit and tree objects; locates the file entry and retrieves its content.
141 | // The function returns an Application model if successful.
142 | // If the file doesn't exist in the target branch, it is assumed to be a new Application.
143 | func (g *GitRepo) getChangedFileContent(targetBranch string, targetFile string) (models.Application, error) {
144 | log.Debugf("Getting content of %s from %s", targetFile, targetBranch)
145 |
146 | // Retrieve the target branch reference.
147 | targetRef, err := g.Repo.Reference(plumbing.ReferenceName("refs/remotes/origin/"+targetBranch), true)
148 | if err != nil {
149 | return models.Application{}, fmt.Errorf("failed to resolve target branch %s: %v", targetBranch, err)
150 | }
151 |
152 | // Get the commit object for the target branch.
153 | targetCommit, err := g.Repo.CommitObject(targetRef.Hash())
154 | if err != nil {
155 | return models.Application{}, fmt.Errorf("failed to get commit object for target branch %s: %v", targetBranch, err)
156 | }
157 |
158 | // Get the tree object for the target commit.
159 | targetTree, err := targetCommit.Tree()
160 | if err != nil {
161 | return models.Application{}, fmt.Errorf("failed to get tree for target commit: %v", err)
162 | }
163 |
164 | // Find the file entry in the tree.
165 | fileEntry, err := targetTree.File(targetFile)
166 | if err != nil {
167 | if errors.Is(err, object.ErrFileNotFound) {
168 | log.Warningf("\u001B[33mThe requested file %s does not exist in target branch %s, assuming it is a new Application\u001B[0m", targetFile, targetBranch)
169 | if !printAddedManifests {
170 | return models.Application{}, gitFileDoesNotExist
171 | }
172 | } else {
173 | return models.Application{}, fmt.Errorf("failed to find file %s in target branch %s: %v", targetFile, targetBranch, err)
174 | }
175 | }
176 |
177 | var fileContent string
178 | if fileEntry == nil {
179 | fileContent = ""
180 | } else {
181 | fileContent, err = fileEntry.Contents()
182 | if err != nil {
183 | return models.Application{}, fmt.Errorf("failed to get contents of file %s: %v", targetFile, err)
184 | }
185 | }
186 |
187 | // Create a temporary file with the content.
188 | tmpFile, err := helpers.CreateTempFile(g.FsType, fileContent)
189 | if err != nil {
190 | return models.Application{}, err
191 | }
192 |
193 | defer func(file afero.File) {
194 | if err := afero.Fs.Remove(g.FsType, file.Name()); err != nil {
195 | log.Errorf("Failed to remove temporary file [%s]: %s", file.Name(), err)
196 | }
197 | }(tmpFile)
198 |
199 | // Create a Target object and parse the application.
200 | target := Target{CmdRunner: g.CmdRunner, FileReader: utils.OsFileReader{}, File: tmpFile.Name()}
201 | if err := target.parse(); err != nil {
202 | return models.Application{}, fmt.Errorf("failed to parse the application: %w", err)
203 | }
204 |
205 | return target.App, nil
206 | }
207 |
208 | // checkIfApp attempts to parse the provided file as an Application.
209 | // Returns true if the parsing is successful, indicating the file is an Application.
210 | func checkIfApp(cmdRunner interfaces.CmdRunner, fileReader interfaces.FileReader, file string) (bool, error) {
211 | log.Debugf("===> Checking if [%s] is an Application", cyan(file))
212 |
213 | target := Target{CmdRunner: cmdRunner, FileReader: fileReader, File: file}
214 |
215 | if err := target.parse(); err != nil {
216 | return false, err
217 | }
218 | return true, nil
219 | }
220 |
221 | // GetGitRepoRoot returns the root directory of the current Git repository.
222 | // It takes a cmdRunner as input, which is an interface for executing shell commands.
223 | // The function runs the "git rev-parse --show-toplevel" command to retrieve the root directory path.
224 | // It captures the standard output and standard error streams and returns them as strings.
225 | // If the command execution is successful, it trims the leading and trailing white spaces from the output and returns it as the repository root directory path.
226 | // If there is an error executing the command, the function prints the error message to standard error and returns an empty string and the error.
227 | func GetGitRepoRoot() (string, error) {
228 | dir, err := os.Getwd()
229 | if err != nil {
230 | return "", fmt.Errorf("failed to get current working directory: %v", err)
231 | }
232 |
233 | for {
234 | _, err := git.PlainOpen(dir)
235 | if err == nil {
236 | return dir, nil
237 | }
238 |
239 | parentDir := filepath.Dir(dir)
240 | if parentDir == dir {
241 | break
242 | }
243 |
244 | dir = parentDir
245 | }
246 |
247 | return "", fmt.Errorf("no git repository found")
248 | }
249 |
250 | // ReadFile reads the contents of the specified file and returns them as a byte slice.
251 | // If the file does not exist, it prints a message indicating that the file was removed in a source branch and returns nil.
252 | // The function handles the os.ErrNotExist error to detect if the file is missing.
253 | func ReadFile(file string) []byte {
254 | if readFile, err := os.ReadFile(file); errors.Is(err, os.ErrNotExist) /* #nosec G304 */ {
255 | return nil
256 | } else {
257 | return readFile
258 | }
259 | }
260 |
261 | // NewGitRepo initializes and returns a GitRepo structure, opening a Git repository at the root location.
262 | // It takes an afero.Fs filesystem and a CmdRunner for shell commands as arguments.
263 | // In case of an error detecting the root of the Git repository or opening it, it returns an error.
264 | func NewGitRepo(fs afero.Fs, cmdRunner interfaces.CmdRunner) (*GitRepo, error) {
265 | repoRoot, err := GetGitRepoRoot()
266 | if err != nil {
267 | return nil, err
268 | }
269 |
270 | gitRepo := &GitRepo{
271 | FsType: fs,
272 | CmdRunner: cmdRunner,
273 | }
274 |
275 | gitRepo.Repo, err = git.PlainOpen(repoRoot)
276 | if err != nil {
277 | return nil, fmt.Errorf("failed to open repository: %v", err)
278 | }
279 |
280 | return gitRepo, nil
281 | }
282 |
--------------------------------------------------------------------------------
/cmd/argo-compare/git_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/shini4i/argo-compare/internal/models"
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/stretchr/testify/require"
13 |
14 | "github.com/spf13/afero"
15 |
16 | "github.com/op/go-logging"
17 | "github.com/shini4i/argo-compare/cmd/argo-compare/mocks"
18 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils"
19 | "github.com/stretchr/testify/assert"
20 | "go.uber.org/mock/gomock"
21 | )
22 |
23 | const (
24 | appFile = "testdata/test.yaml"
25 | )
26 |
27 | func init() {
28 | // We don't want to see any logs in tests
29 | loggingInit(logging.CRITICAL)
30 | }
31 |
32 | func TestCheckIfApp(t *testing.T) {
33 | ctrl := gomock.NewController(t)
34 | defer ctrl.Finish()
35 |
36 | // Create the mocks
37 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl)
38 |
39 | isApp, err := checkIfApp(mockCmdRunner, utils.OsFileReader{}, appFile)
40 |
41 | assert.True(t, isApp, "expected true, got false")
42 | assert.NoError(t, err, "expected no error, got %v", err)
43 | }
44 |
45 | func TestNewGitRepo(t *testing.T) {
46 | ctrl := gomock.NewController(t)
47 | defer ctrl.Finish()
48 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl)
49 |
50 | fs := afero.NewMemMapFs()
51 |
52 | repo, err := NewGitRepo(fs, mockCmdRunner)
53 |
54 | assert.NoError(t, err)
55 | assert.NotNil(t, repo.Repo)
56 | assert.IsType(t, fs, repo.FsType)
57 | assert.IsType(t, mockCmdRunner, repo.CmdRunner)
58 | }
59 |
60 | func TestGitInteraction(t *testing.T) {
61 | // Save the initial working directory
62 | originalDir, err := os.Getwd()
63 | require.NoError(t, err, "Failed to get original working directory")
64 |
65 | defer func() {
66 | // Change back to the original working directory
67 | err = os.Chdir(originalDir)
68 | require.NoError(t, err, "Failed to change back to the original working directory")
69 | }()
70 |
71 | // Create temporary directory for cloning
72 | tempDir, err := os.MkdirTemp("", "gitTest")
73 | require.NoError(t, err)
74 |
75 | defer os.RemoveAll(tempDir) // clean up
76 |
77 | // Clone the bare repo to our temporary directory
78 | repo, err := git.PlainClone(tempDir, false, &git.CloneOptions{
79 | URL: "../../testdata/repo.git",
80 | SingleBranch: false,
81 | })
82 | require.NoError(t, err, "Failed to clone repository")
83 |
84 | err = repo.Fetch(&git.FetchOptions{
85 | RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"},
86 | })
87 | require.NoError(t, err, "Failed to fetch")
88 |
89 | // Switch to the "feature" branch
90 | w, err := repo.Worktree()
91 | require.NoError(t, err, "Failed to get worktree")
92 |
93 | branchRef := plumbing.NewBranchReferenceName("feature-branch")
94 | err = w.Checkout(&git.CheckoutOptions{
95 | Branch: branchRef,
96 | })
97 | require.NoError(t, err, "Failed to checkout feature branch")
98 |
99 | // Initialize GitRepo with cloned repo and proceed with testing
100 | target := GitRepo{
101 | Repo: repo,
102 | FsType: afero.NewOsFs(),
103 | CmdRunner: &utils.RealCmdRunner{},
104 | }
105 |
106 | targetBranch = "main"
107 |
108 | // Change the working directory to the temporary directory
109 | err = os.Chdir(tempDir)
110 | require.NoError(t, err, "Failed to change working directory to tempDir")
111 |
112 | t.Run("get changed files", func(t *testing.T) {
113 | changedFiles, err := target.getChangedFiles(utils.OsFileReader{})
114 | assert.NoError(t, err, "Failed to get changed files")
115 | assert.Equal(t, []string{"cluster-state/web/ingress-nginx.yaml"}, changedFiles)
116 | })
117 |
118 | t.Run("get changed file content", func(t *testing.T) {
119 | fileContent, err := target.getChangedFileContent("main", "cluster-state/web/ingress-nginx.yaml")
120 | expectedApp := models.Application{
121 | Kind: "Application",
122 | Metadata: struct {
123 | Name string `yaml:"name"`
124 | Namespace string `yaml:"namespace"`
125 | }{
126 | Name: "ingress-nginx",
127 | Namespace: "argo-cd",
128 | },
129 | Spec: struct {
130 | Source *models.Source `yaml:"source"`
131 | Sources []*models.Source `yaml:"sources"`
132 | MultiSource bool `yaml:"-"`
133 | }{
134 | Source: &models.Source{
135 | TargetRevision: "4.9.1",
136 | },
137 | },
138 | }
139 | assert.NoError(t, err)
140 | assert.Equal(t, expectedApp.Metadata.Name, fileContent.Metadata.Name)
141 | assert.Equal(t, expectedApp.Spec.Source.TargetRevision, fileContent.Spec.Source.TargetRevision)
142 | })
143 | }
144 |
--------------------------------------------------------------------------------
/cmd/argo-compare/interfaces/interfaces.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/shini4i/argo-compare/internal/models"
7 | )
8 |
9 | type CmdRunner interface {
10 | Run(cmd string, args ...string) (stdout string, stderr string, err error)
11 | }
12 |
13 | type OsFs interface {
14 | CreateTemp(dir, pattern string) (f *os.File, err error)
15 | Remove(name string) error
16 | }
17 |
18 | type FileReader interface {
19 | ReadFile(file string) []byte
20 | }
21 |
22 | type Globber interface {
23 | Glob(pattern string) ([]string, error)
24 | }
25 |
26 | type HelmChartsProcessor interface {
27 | GenerateValuesFile(chartName, tmpDir, targetType, values string, valuesObject map[string]interface{}) error
28 | DownloadHelmChart(cmdRunner CmdRunner, globber Globber, cacheDir, repoUrl, chartName, targetRevision string, repoCredentials []models.RepoCredentials) error
29 | ExtractHelmChart(cmdRunner CmdRunner, globber Globber, chartName, chartVersion, chartLocation, tmpDir, targetType string) error
30 | RenderAppSource(cmdRunner CmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType string) error
31 | }
32 |
--------------------------------------------------------------------------------
/cmd/argo-compare/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "slices"
9 | "strings"
10 |
11 | "github.com/alecthomas/kong"
12 | "github.com/fatih/color"
13 | "github.com/op/go-logging"
14 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces"
15 | "github.com/spf13/afero"
16 |
17 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils"
18 | "github.com/shini4i/argo-compare/internal/helpers"
19 | "github.com/shini4i/argo-compare/internal/models"
20 | )
21 |
22 | const (
23 | loggerName = "argo-compare"
24 | repoCredsPrefix = "REPO_CREDS_" // #nosec G101
25 | )
26 |
27 | var (
28 | cacheDir = helpers.GetEnv("ARGO_COMPARE_CACHE_DIR", fmt.Sprintf("%s/.cache/argo-compare", os.Getenv("HOME")))
29 | tmpDir string
30 | version = "local"
31 | repoCredentials []models.RepoCredentials
32 | externalDiffTool = os.Getenv("EXTERNAL_DIFF_TOOL")
33 | )
34 |
35 | var (
36 | log = logging.MustGetLogger(loggerName)
37 | // A bit weird, but it seems that it's the easiest way to implement log level support in CLI tool
38 | // without printing the log level and timestamp in the output.
39 | format = logging.MustStringFormatter(
40 | `%{message}`,
41 | )
42 | helmChartProcessor = utils.RealHelmChartProcessor{Log: log}
43 | )
44 |
45 | var (
46 | cyan = color.New(color.FgCyan, color.Bold).SprintFunc()
47 | red = color.New(color.FgRed, color.Bold).SprintFunc()
48 | yellow = color.New(color.FgYellow, color.Bold).SprintFunc()
49 | )
50 |
51 | func loggingInit(level logging.Level) {
52 | backend := logging.NewLogBackend(os.Stdout, "", 0)
53 | backendFormatter := logging.NewBackendFormatter(backend, format)
54 | logging.SetBackend(backendFormatter)
55 | logging.SetLevel(level, "")
56 | }
57 |
58 | func processFiles(cmdRunner interfaces.CmdRunner, fileName string, fileType string, application models.Application) error {
59 | log.Debugf("Processing [%s] file: [%s]", cyan(fileType), cyan(fileName))
60 |
61 | target := Target{CmdRunner: cmdRunner, FileReader: utils.OsFileReader{}, File: fileName, Type: fileType, App: application}
62 | if fileType == "src" {
63 | if err := target.parse(); err != nil {
64 | return err
65 | }
66 | }
67 |
68 | if err := target.generateValuesFiles(helmChartProcessor); err != nil {
69 | return err
70 | }
71 |
72 | if err := target.ensureHelmCharts(helmChartProcessor); err != nil {
73 | return err
74 | }
75 |
76 | if err := target.extractCharts(helmChartProcessor); err != nil {
77 | return err
78 | }
79 |
80 | if err := target.renderAppSources(helmChartProcessor); err != nil {
81 | return err
82 | }
83 |
84 | return nil
85 | }
86 |
87 | func compareFiles(fs afero.Fs, cmdRunner interfaces.CmdRunner, repo *GitRepo, changedFiles []string) {
88 | for _, file := range changedFiles {
89 | var err error
90 |
91 | log.Infof("===> Processing changed application: [%s]", cyan(file))
92 |
93 | if tmpDir, err = afero.TempDir(fs, "/tmp", "argo-compare-"); err != nil {
94 | log.Panic(err)
95 | }
96 |
97 | if err = processFiles(cmdRunner, file, "src", models.Application{}); err != nil {
98 | log.Panicf("Could not process the source Application: %s", err)
99 | }
100 |
101 | app, err := repo.getChangedFileContent(targetBranch, file)
102 | if errors.Is(err, gitFileDoesNotExist) && !printAddedManifests {
103 | return
104 | } else if err != nil && !errors.Is(err, models.EmptyFileError) {
105 | log.Errorf("Could not get the target Application from branch [%s]: %s", targetBranch, err)
106 | }
107 |
108 | if !errors.Is(err, models.EmptyFileError) {
109 | if err = processFiles(cmdRunner, file, "dst", app); err != nil && !printAddedManifests {
110 | log.Panicf("Could not process the destination Application: %s", err)
111 | }
112 | }
113 |
114 | runComparison(cmdRunner)
115 |
116 | if err := fs.RemoveAll(tmpDir); err != nil {
117 | log.Panic(err)
118 | }
119 | }
120 | }
121 |
122 | func runComparison(cmdRunner interfaces.CmdRunner) {
123 | comparer := Compare{
124 | CmdRunner: cmdRunner,
125 | externalDiffTool: externalDiffTool,
126 | }
127 | comparer.findFiles()
128 | comparer.printFilesStatus()
129 | }
130 |
131 | func collectRepoCredentials() error {
132 | log.Debug("===> Collecting repo credentials")
133 | for _, env := range os.Environ() {
134 | if strings.HasPrefix(env, repoCredsPrefix) {
135 | var repoCreds models.RepoCredentials
136 | if err := json.Unmarshal([]byte(strings.SplitN(env, "=", 2)[1]), &repoCreds); err != nil {
137 | return err
138 | }
139 | repoCredentials = append(repoCredentials, repoCreds)
140 | }
141 | }
142 |
143 | for _, repo := range repoCredentials {
144 | log.Debugf("▶ Found repo credentials for [%s]", cyan(repo.Url))
145 | }
146 |
147 | return nil
148 | }
149 |
150 | func printInvalidFilesList(repo *GitRepo) error {
151 | if len(repo.invalidFiles) > 0 {
152 | log.Info("===> The following yaml files are invalid and were skipped")
153 | for _, file := range repo.invalidFiles {
154 | log.Warningf("▶ %s", file)
155 | }
156 | return errors.New("invalid files found")
157 | }
158 | return nil
159 | }
160 |
161 | // parseCli processes command-line arguments, setting appropriate global variables based on user input.
162 | // If the user does not provide a recognized command, it returns an error.
163 | func parseCli() error {
164 | ctx := kong.Parse(&CLI,
165 | kong.Name("argo-compare"),
166 | kong.Description("Compare ArgoCD applications between git branches"),
167 | kong.UsageOnError(),
168 | kong.Vars{"version": version})
169 |
170 | switch ctx.Command() {
171 | case "branch ":
172 | targetBranch = CLI.Branch.Name
173 | fileToCompare = CLI.Branch.File
174 | filesToIgnore = CLI.Branch.Ignore
175 | }
176 |
177 | return nil
178 | }
179 |
180 | func runCLI() error {
181 | if err := parseCli(); err != nil {
182 | return err
183 | }
184 |
185 | updateConfigurations()
186 |
187 | repo, err := NewGitRepo(afero.NewOsFs(), &utils.RealCmdRunner{})
188 | if err != nil {
189 | return err
190 | }
191 |
192 | log.Infof("===> Running Argo Compare version [%s]", cyan(version))
193 |
194 | if err := collectRepoCredentials(); err != nil {
195 | return err
196 | }
197 |
198 | changedFiles, err := getChangedFiles(repo, fileToCompare, filesToIgnore)
199 | if err != nil {
200 | return err
201 | }
202 |
203 | if len(changedFiles) == 0 {
204 | log.Info("No changed Application files found. Exiting...")
205 | } else {
206 | compareFiles(afero.NewOsFs(), &utils.RealCmdRunner{}, repo, changedFiles)
207 | }
208 |
209 | return printInvalidFilesList(repo)
210 | }
211 |
212 | func getChangedFiles(repo *GitRepo, fileToCompare string, filesToIgnore []string) ([]string, error) {
213 | var changedFiles []string
214 | var err error
215 |
216 | if fileToCompare != "" {
217 | changedFiles = []string{fileToCompare}
218 | } else {
219 | changedFiles, err = repo.getChangedFiles(utils.OsFileReader{})
220 | if err != nil {
221 | return nil, err
222 | }
223 | }
224 |
225 | filteredChangedFiles := slices.DeleteFunc(changedFiles, func(file string) bool {
226 | return slices.Contains(filesToIgnore, file)
227 | })
228 |
229 | return filteredChangedFiles, nil
230 | }
231 |
232 | func updateConfigurations() {
233 | if CLI.Debug {
234 | loggingInit(logging.DEBUG)
235 | } else {
236 | loggingInit(logging.INFO)
237 | }
238 |
239 | if CLI.Branch.PreserveHelmLabels {
240 | preserveHelmLabels = true
241 | }
242 |
243 | if CLI.Branch.PrintAddedManifests {
244 | printAddedManifests = true
245 | }
246 |
247 | if CLI.Branch.PrintRemovedManifests {
248 | printRemovedManifests = true
249 | }
250 |
251 | if CLI.Branch.FullOutput {
252 | printAddedManifests = true
253 | printRemovedManifests = true
254 | }
255 | }
256 |
257 | func main() {
258 | if err := runCLI(); err != nil {
259 | log.Fatal(err)
260 | }
261 | }
262 |
--------------------------------------------------------------------------------
/cmd/argo-compare/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/op/go-logging"
8 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils"
9 | "github.com/spf13/afero"
10 | "github.com/stretchr/testify/assert"
11 | )
12 |
13 | func TestCollectRepoCredentials(t *testing.T) {
14 | // Test case 1: repo credentials are set and valid
15 | envVars := map[string]string{
16 | "REPO_CREDS_1": `{"url":"https://example.com","username":"user1","password":"pass1"}`,
17 | "REPO_CREDS_2": `{"url":"https://example.org","username":"user2","password":"pass2"}`,
18 | "REPO_CREDS_3": `{"url":"https://example.net","username":"user3","password":"pass3"}`,
19 | }
20 | for key, value := range envVars {
21 | err := os.Setenv(key, value)
22 | if err != nil {
23 | t.Fatalf("failed to set environment variable %q: %v", key, err)
24 | }
25 | }
26 |
27 | if err := collectRepoCredentials(); err != nil {
28 | t.Fatalf("failed to collect repo credentials: %v", err)
29 | }
30 |
31 | expectedUrls := []string{"https://example.com", "https://example.org", "https://example.net"}
32 | for _, expectedUrl := range expectedUrls {
33 | found := false
34 | for _, repo := range repoCredentials {
35 | if repo.Url == expectedUrl {
36 | found = true
37 | break
38 | }
39 | }
40 | assert.True(t, found, "expected to find repo credentials for [%s], but none were found", expectedUrl)
41 | }
42 |
43 | // Test case 2: repo credentials are set but invalid
44 | envVars = map[string]string{
45 | "REPO_CREDS_1": `{"url":"https://example.com","username":"user1","password":"pass1"}`,
46 | "REPO_CREDS_2": `{"url":"https://example.org","username":"user2","password":"pass2"}`,
47 | "REPO_CREDS_3": `{"url":"https://example.net","username":"user3","password":"pass3"`,
48 | }
49 |
50 | for key, value := range envVars {
51 | err := os.Setenv(key, value)
52 | if err != nil {
53 | t.Fatalf("failed to set environment variable %q: %v", key, err)
54 | }
55 | }
56 |
57 | assert.Error(t, collectRepoCredentials(), "expected to get an error when repo credentials are invalid")
58 | }
59 |
60 | func TestLoggingInit(t *testing.T) {
61 | // Run the function being tested
62 | loggingInit(logging.DEBUG)
63 |
64 | // Check the result
65 | if logging.GetLevel("") != logging.DEBUG {
66 | t.Errorf("logging level not set to DEBUG")
67 | }
68 | }
69 |
70 | func TestInvalidFilesList(t *testing.T) {
71 | // Test case 1: invalid files list is not empty
72 | repo := GitRepo{FsType: afero.NewOsFs(), CmdRunner: &utils.RealCmdRunner{}}
73 | repo.invalidFiles = []string{"file1", "file2", "file3"}
74 |
75 | err := printInvalidFilesList(&repo)
76 | // We need to return the error if any of the files is invalid
77 | assert.Error(t, err)
78 |
79 | // Test case 2: invalid files list is empty
80 | repo.invalidFiles = []string{}
81 |
82 | err = printInvalidFilesList(&repo)
83 | // If the list is empty, we should not return an error
84 | assert.NoError(t, err)
85 | }
86 |
87 | func TestCliCommands(t *testing.T) {
88 | oldArgs := os.Args
89 | defer func() { os.Args = oldArgs }()
90 |
91 | testCases := []struct {
92 | name string
93 | args []string
94 | expectedBranch string
95 | expectedAdded bool
96 | expectedRemoved bool
97 | expectedPreserve bool
98 | }{
99 | {"minimal input", []string{"cmd", "branch", "main"}, "main", false, false, false},
100 | {"full output", []string{"cmd", "branch", "main", "--full-output"}, "main", true, true, false},
101 | {"print added", []string{"cmd", "branch", "main", "--print-added-manifests"}, "main", true, false, false},
102 | {"print removed", []string{"cmd", "branch", "main", "--print-removed-manifests"}, "main", false, true, false},
103 | {"preserve helm labels", []string{"cmd", "branch", "main", "--preserve-helm-labels"}, "main", false, false, true},
104 | }
105 |
106 | for _, tc := range testCases {
107 | t.Run(tc.name, func(t *testing.T) {
108 | os.Args = tc.args
109 | err := parseCli()
110 | assert.NoErrorf(t, err, "expected to get no error when parsing valid command")
111 | assert.Equal(t, tc.expectedBranch, targetBranch)
112 |
113 | updateConfigurations()
114 |
115 | assert.Equal(t, tc.expectedAdded, printAddedManifests)
116 | assert.Equal(t, tc.expectedRemoved, printRemovedManifests)
117 | assert.Equal(t, tc.expectedPreserve, preserveHelmLabels)
118 |
119 | // Reset global vars
120 | targetBranch = ""
121 | printAddedManifests = false
122 | printRemovedManifests = false
123 | preserveHelmLabels = false
124 | })
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/cmd/argo-compare/target.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces"
8 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils"
9 | "gopkg.in/yaml.v3"
10 |
11 | "github.com/shini4i/argo-compare/internal/models"
12 | )
13 |
14 | type Target struct {
15 | CmdRunner interfaces.CmdRunner
16 | FileReader interfaces.FileReader
17 | File string
18 | Type string // src or dst version
19 | App models.Application
20 | }
21 |
22 | // parse reads the YAML content from a file and unmarshals it into an Application model.
23 | // It uses the FileReader interface to support different implementations for file reading.
24 | // Returns an error in case of issues during reading, unmarshaling or validation.
25 | func (t *Target) parse() error {
26 | app := models.Application{}
27 |
28 | var file string
29 |
30 | // if we are working with a temporary file, we don't need to prepend the repo root path
31 | if !strings.Contains(t.File, "/tmp/") {
32 | if gitRepoRoot, err := GetGitRepoRoot(); err != nil {
33 | return err
34 | } else {
35 | file = fmt.Sprintf("%s/%s", gitRepoRoot, t.File)
36 | }
37 | } else {
38 | file = t.File
39 | }
40 |
41 | log.Debugf("Parsing %s...", file)
42 |
43 | yamlContent := t.FileReader.ReadFile(file)
44 | if err := yaml.Unmarshal(yamlContent, &app); err != nil {
45 | return err
46 | }
47 |
48 | if err := app.Validate(); err != nil {
49 | return err
50 | }
51 |
52 | t.App = app
53 |
54 | return nil
55 | }
56 |
57 | // generateValuesFiles generates Helm values files for the application's sources.
58 | // If the application uses multiple sources, a separate values file is created for each source.
59 | // Otherwise, a single values file is generated for the application's single source.
60 | func (t *Target) generateValuesFiles(helmChartProcessor interfaces.HelmChartsProcessor) error {
61 | if t.App.Spec.MultiSource {
62 | for _, source := range t.App.Spec.Sources {
63 | if err := helmChartProcessor.GenerateValuesFile(source.Chart, tmpDir, t.Type, source.Helm.Values, source.Helm.ValuesObject); err != nil {
64 | return err
65 | }
66 | }
67 | } else {
68 | if err := helmChartProcessor.GenerateValuesFile(t.App.Spec.Source.Chart, tmpDir, t.Type, t.App.Spec.Source.Helm.Values, t.App.Spec.Source.Helm.ValuesObject); err != nil {
69 | return err
70 | }
71 | }
72 | return nil
73 | }
74 |
75 | // ensureHelmCharts downloads Helm charts for the application's sources.
76 | // If the application uses multiple sources, each chart is downloaded separately.
77 | // If the application has a single source, only the respective chart is downloaded.
78 | // In case of any error during download, the error is returned immediately.
79 | func (t *Target) ensureHelmCharts(helmChartProcessor interfaces.HelmChartsProcessor) error {
80 | if t.App.Spec.MultiSource {
81 | for _, source := range t.App.Spec.Sources {
82 | if err := helmChartProcessor.DownloadHelmChart(t.CmdRunner, utils.CustomGlobber{}, cacheDir, source.RepoURL, source.Chart, source.TargetRevision, repoCredentials); err != nil {
83 | return err
84 | }
85 | }
86 | } else {
87 | if err := helmChartProcessor.DownloadHelmChart(t.CmdRunner, utils.CustomGlobber{}, cacheDir, t.App.Spec.Source.RepoURL, t.App.Spec.Source.Chart, t.App.Spec.Source.TargetRevision, repoCredentials); err != nil {
88 | return err
89 | }
90 | }
91 |
92 | return nil
93 | }
94 |
95 | // extractCharts extracts the content of the downloaded Helm charts.
96 | // For applications with multiple sources, each chart is extracted separately.
97 | // For single-source applications, only the corresponding chart is extracted.
98 | // If an error occurs during extraction, the program is terminated.
99 | func (t *Target) extractCharts(helmChartProcessor interfaces.HelmChartsProcessor) error {
100 | // We have a separate function for this and not using helm to extract the content of the chart
101 | // because we don't want to re-download the chart if the TargetRevision is the same
102 | if t.App.Spec.MultiSource {
103 | for _, source := range t.App.Spec.Sources {
104 | err := helmChartProcessor.ExtractHelmChart(t.CmdRunner, utils.CustomGlobber{}, source.Chart, source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, source.RepoURL), tmpDir, t.Type)
105 | if err != nil {
106 | return err
107 | }
108 | }
109 | } else {
110 | err := helmChartProcessor.ExtractHelmChart(t.CmdRunner, utils.CustomGlobber{}, t.App.Spec.Source.Chart, t.App.Spec.Source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, t.App.Spec.Source.RepoURL), tmpDir, t.Type)
111 | if err != nil {
112 | return err
113 | }
114 | }
115 | return nil
116 | }
117 |
118 | // renderAppSources uses Helm to render chart templates for the application's sources.
119 | // If the Helm specification provides a release name, it is used; otherwise, the application's metadata name is used.
120 | // If the application has multiple sources, each source is rendered individually.
121 | // If the application has only one source, the source is rendered accordingly.
122 | // If there's any error during rendering, it will lead to a fatal error, and the program will exit.
123 | func (t *Target) renderAppSources(helmChartProcessor interfaces.HelmChartsProcessor) error {
124 | var releaseName string
125 |
126 | // We are providing release name to the helm template command to cover some corner cases
127 | // when the chart is using the release name in the templates
128 | if !t.App.Spec.MultiSource {
129 | if t.App.Spec.Source.Helm.ReleaseName != "" {
130 | releaseName = t.App.Spec.Source.Helm.ReleaseName
131 | } else {
132 | releaseName = t.App.Metadata.Name
133 | }
134 | }
135 |
136 | if t.App.Spec.MultiSource {
137 | for _, source := range t.App.Spec.Sources {
138 | if source.Helm.ReleaseName != "" {
139 | releaseName = source.Helm.ReleaseName
140 | } else {
141 | releaseName = t.App.Metadata.Name
142 | }
143 | if err := helmChartProcessor.RenderAppSource(&utils.RealCmdRunner{}, releaseName, source.Chart, source.TargetRevision, tmpDir, t.Type); err != nil {
144 | return err
145 | }
146 | }
147 | return nil
148 | }
149 |
150 | if err := helmChartProcessor.RenderAppSource(&utils.RealCmdRunner{}, releaseName, t.App.Spec.Source.Chart, t.App.Spec.Source.TargetRevision, tmpDir, t.Type); err != nil {
151 | return err
152 | }
153 |
154 | return nil
155 | }
156 |
--------------------------------------------------------------------------------
/cmd/argo-compare/target_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "testing"
7 |
8 | "github.com/shini4i/argo-compare/cmd/argo-compare/mocks"
9 | "github.com/shini4i/argo-compare/cmd/argo-compare/utils"
10 | "github.com/stretchr/testify/assert"
11 | "go.uber.org/mock/gomock"
12 | )
13 |
14 | const (
15 | testsDir = "../../testdata/disposable"
16 | )
17 |
18 | func TestTarget_generateValuesFiles(t *testing.T) {
19 | ctrl := gomock.NewController(t)
20 | defer ctrl.Finish()
21 |
22 | // Create an instance of the mock HelmChartsProcessor
23 | mockHelmValuesGenerator := mocks.NewMockHelmChartsProcessor(ctrl)
24 |
25 | app := Target{
26 | CmdRunner: &utils.RealCmdRunner{},
27 | FileReader: &utils.OsFileReader{},
28 | File: appFile,
29 | }
30 |
31 | err := app.parse()
32 | assert.NoError(t, err)
33 |
34 | // Test case 1: Successful values file generation with single source Application
35 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("ingress-nginx", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(nil)
36 |
37 | err = app.generateValuesFiles(mockHelmValuesGenerator)
38 | assert.NoError(t, err)
39 |
40 | // Test case 2: Successful values file generation with multiple source Applications
41 | app2 := Target{
42 | CmdRunner: &utils.RealCmdRunner{},
43 | FileReader: &utils.OsFileReader{},
44 | File: "testdata/test2.yaml",
45 | }
46 |
47 | err = app2.parse()
48 | assert.NoError(t, err)
49 |
50 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("kubed", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(nil)
51 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("sealed-secrets", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(nil)
52 |
53 | err = app2.generateValuesFiles(mockHelmValuesGenerator)
54 | assert.NoError(t, err)
55 |
56 | // Test case 3: Failed values file generation with single source Application
57 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("ingress-nginx", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(errors.New("some unexpected error"))
58 | err = app.generateValuesFiles(mockHelmValuesGenerator)
59 | assert.ErrorContains(t, err, "some unexpected error")
60 |
61 | // Test case 4: Failed values file generation with multiple source Applications
62 | mockHelmValuesGenerator.EXPECT().GenerateValuesFile("kubed", gomock.Any(), gomock.Any(), gomock.Any(), nil).Return(errors.New("multiple source apps error"))
63 | err = app2.generateValuesFiles(mockHelmValuesGenerator)
64 | assert.ErrorContains(t, err, "multiple source apps error")
65 | }
66 |
67 | func TestTarget_ensureHelmCharts(t *testing.T) {
68 | ctrl := gomock.NewController(t)
69 | defer ctrl.Finish()
70 |
71 | // Create an instance of the mock HelmChartProcessor
72 | mockHelmChartProcessor := mocks.NewMockHelmChartsProcessor(ctrl)
73 |
74 | // Test case 1: Single source chart download success
75 | app := Target{
76 | CmdRunner: &utils.RealCmdRunner{},
77 | FileReader: &utils.OsFileReader{},
78 | File: appFile,
79 | }
80 | err := app.parse()
81 | assert.NoError(t, err)
82 |
83 | mockHelmChartProcessor.EXPECT().DownloadHelmChart(app.CmdRunner, utils.CustomGlobber{}, cacheDir, app.App.Spec.Source.RepoURL, app.App.Spec.Source.Chart, app.App.Spec.Source.TargetRevision, repoCredentials).Return(nil)
84 |
85 | err = app.ensureHelmCharts(mockHelmChartProcessor)
86 | assert.NoError(t, err)
87 |
88 | // Test case 2: Multiple source chart downloads success
89 | app2 := Target{
90 | CmdRunner: &utils.RealCmdRunner{},
91 | FileReader: &utils.OsFileReader{},
92 | File: "testdata/test2.yaml",
93 | }
94 | err = app2.parse()
95 | assert.NoError(t, err)
96 |
97 | for _, source := range app2.App.Spec.Sources {
98 | mockHelmChartProcessor.EXPECT().DownloadHelmChart(app2.CmdRunner, utils.CustomGlobber{}, cacheDir, source.RepoURL, source.Chart, source.TargetRevision, repoCredentials).Return(nil)
99 | }
100 |
101 | err = app2.ensureHelmCharts(mockHelmChartProcessor)
102 | assert.NoError(t, err)
103 |
104 | // Test case 3: Single source chart download failure
105 | mockHelmChartProcessor.EXPECT().DownloadHelmChart(app.CmdRunner, utils.CustomGlobber{}, cacheDir, app.App.Spec.Source.RepoURL, app.App.Spec.Source.Chart, app.App.Spec.Source.TargetRevision, repoCredentials).Return(errors.New("some download error"))
106 |
107 | err = app.ensureHelmCharts(mockHelmChartProcessor)
108 | assert.ErrorContains(t, err, "some download error")
109 |
110 | // Test case 4: Multiple source chart downloads failure
111 | mockHelmChartProcessor.EXPECT().DownloadHelmChart(app2.CmdRunner, utils.CustomGlobber{}, cacheDir, app2.App.Spec.Sources[0].RepoURL, app2.App.Spec.Sources[0].Chart, app2.App.Spec.Sources[0].TargetRevision, repoCredentials).Return(errors.New("multiple download error"))
112 |
113 | err = app2.ensureHelmCharts(mockHelmChartProcessor)
114 | assert.ErrorContains(t, err, "multiple download error")
115 | }
116 |
117 | func TestTarget_extractCharts(t *testing.T) {
118 | ctrl := gomock.NewController(t)
119 | defer ctrl.Finish()
120 |
121 | // Create an instance of the mock HelmChartsProcessor
122 | mockHelmChartProcessor := mocks.NewMockHelmChartsProcessor(ctrl)
123 |
124 | // Test case 1: Single source chart extraction success
125 | app := Target{
126 | CmdRunner: &utils.RealCmdRunner{},
127 | FileReader: &utils.OsFileReader{},
128 | File: appFile,
129 | }
130 | err := app.parse()
131 | assert.NoError(t, err)
132 |
133 | mockHelmChartProcessor.EXPECT().ExtractHelmChart(app.CmdRunner, utils.CustomGlobber{}, app.App.Spec.Source.Chart, app.App.Spec.Source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, app.App.Spec.Source.RepoURL), tmpDir, app.Type).Return(nil)
134 |
135 | err = app.extractCharts(mockHelmChartProcessor)
136 | assert.NoError(t, err)
137 |
138 | // Test case 2: Multiple source chart extractions success
139 | app2 := Target{
140 | CmdRunner: &utils.RealCmdRunner{},
141 | FileReader: &utils.OsFileReader{},
142 | File: "testdata/test2.yaml",
143 | }
144 | err = app2.parse()
145 | assert.NoError(t, err)
146 |
147 | for _, source := range app2.App.Spec.Sources {
148 | mockHelmChartProcessor.EXPECT().ExtractHelmChart(app2.CmdRunner, utils.CustomGlobber{}, source.Chart, source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, source.RepoURL), tmpDir, app2.Type).Return(nil)
149 | }
150 |
151 | err = app2.extractCharts(mockHelmChartProcessor)
152 | assert.NoError(t, err)
153 |
154 | // Test case 3: Single source chart extraction failure
155 | mockHelmChartProcessor.EXPECT().ExtractHelmChart(app.CmdRunner, utils.CustomGlobber{}, app.App.Spec.Source.Chart, app.App.Spec.Source.TargetRevision, fmt.Sprintf("%s/%s", cacheDir, app.App.Spec.Source.RepoURL), tmpDir, app.Type).Return(errors.New("some extraction error"))
156 |
157 | err = app.extractCharts(mockHelmChartProcessor)
158 | assert.ErrorContains(t, err, "some extraction error")
159 |
160 | // Test case 4: Multiple source chart extractions failure
161 | mockHelmChartProcessor.EXPECT().ExtractHelmChart(app2.CmdRunner, utils.CustomGlobber{}, app2.App.Spec.Sources[0].Chart, app2.App.Spec.Sources[0].TargetRevision, fmt.Sprintf("%s/%s", cacheDir, app2.App.Spec.Sources[0].RepoURL), tmpDir, app2.Type).Return(errors.New("multiple extraction error"))
162 |
163 | err = app2.extractCharts(mockHelmChartProcessor)
164 | assert.ErrorContains(t, err, "multiple extraction error")
165 | }
166 |
167 | func TestTarget_renderAppSources(t *testing.T) {
168 | ctrl := gomock.NewController(t)
169 | defer ctrl.Finish()
170 |
171 | // Create a mock HelmChartsProcessor
172 | mockHelmChartProcessor := mocks.NewMockHelmChartsProcessor(ctrl)
173 |
174 | // Test case 1: Single source rendering success
175 | app := Target{
176 | CmdRunner: &utils.RealCmdRunner{},
177 | FileReader: &utils.OsFileReader{},
178 | File: appFile,
179 | }
180 | err := app.parse()
181 | assert.NoError(t, err)
182 |
183 | mockHelmChartProcessor.EXPECT().RenderAppSource(app.CmdRunner, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
184 |
185 | err = app.renderAppSources(mockHelmChartProcessor)
186 | assert.NoError(t, err)
187 |
188 | // Test case 2: Multiple source rendering success
189 | app2 := Target{
190 | CmdRunner: &utils.RealCmdRunner{},
191 | FileReader: &utils.OsFileReader{},
192 | File: "testdata/test2.yaml",
193 | }
194 | err = app2.parse()
195 | assert.NoError(t, err)
196 |
197 | for _, source := range app2.App.Spec.Sources {
198 | mockHelmChartProcessor.EXPECT().RenderAppSource(app2.CmdRunner, gomock.Any(), source.Chart, source.TargetRevision, gomock.Any(), gomock.Any()).Return(nil)
199 | }
200 |
201 | err = app2.renderAppSources(mockHelmChartProcessor)
202 | assert.NoError(t, err)
203 |
204 | // Test case 3: Single source rendering failure
205 | mockHelmChartProcessor.EXPECT().RenderAppSource(app.CmdRunner, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("some rendering error"))
206 |
207 | err = app.renderAppSources(mockHelmChartProcessor)
208 | assert.ErrorContains(t, err, "some rendering error")
209 |
210 | // Test case 4: Multiple source rendering failure
211 | mockHelmChartProcessor.EXPECT().RenderAppSource(app2.CmdRunner, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("multiple rendering error"))
212 |
213 | err = app2.renderAppSources(mockHelmChartProcessor)
214 | assert.ErrorContains(t, err, "multiple rendering error")
215 | }
216 |
--------------------------------------------------------------------------------
/cmd/argo-compare/utils/cmdrunner.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bytes"
5 | "os/exec"
6 | )
7 |
8 | type RealCmdRunner struct{}
9 |
10 | func (r *RealCmdRunner) Run(cmd string, args ...string) (string, string, error) {
11 | command := exec.Command(cmd, args...)
12 |
13 | var stdoutBuffer, stderrBuffer bytes.Buffer
14 | command.Stdout = &stdoutBuffer
15 | command.Stderr = &stderrBuffer
16 |
17 | err := command.Run()
18 |
19 | return stdoutBuffer.String(), stderrBuffer.String(), err
20 | }
21 |
--------------------------------------------------------------------------------
/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 | type OsFileReader struct{}
9 |
10 | func (r OsFileReader) ReadFile(file string) []byte {
11 | if readFile, err := os.ReadFile(file); errors.Is(err, os.ErrNotExist) /* #nosec G304 */ {
12 | return nil
13 | } else {
14 | return readFile
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/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/globber.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "github.com/mattn/go-zglob"
4 |
5 | type CustomGlobber struct{}
6 |
7 | func (g CustomGlobber) Glob(pattern string) ([]string, error) {
8 | return zglob.Glob(pattern)
9 | }
10 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | interfaces "github.com/shini4i/argo-compare/cmd/argo-compare/interfaces"
16 | "github.com/shini4i/argo-compare/internal/models"
17 | )
18 |
19 | var (
20 | cyan = color.New(color.FgCyan, color.Bold).SprintFunc()
21 | FailedToDownloadChart = errors.New("failed to download chart")
22 | )
23 |
24 | type RealHelmChartProcessor struct {
25 | Log *logging.Logger
26 | }
27 |
28 | // GenerateValuesFile creates a Helm values file for a given chart in a specified directory.
29 | // It takes a chart name, a temporary directory for storing the file, the target type categorizing the application,
30 | // and the content of the values file in string format.
31 | // The function first attempts to create the file. If an error occurs, it terminates the program.
32 | // Next, it writes the values string to the file. If an error occurs during this process, the program is also terminated.
33 | func (g RealHelmChartProcessor) GenerateValuesFile(chartName, tmpDir, targetType, values string, valuesObject map[string]interface{}) error {
34 | yamlFile, err := os.Create(fmt.Sprintf("%s/%s-values-%s.yaml", tmpDir, chartName, targetType))
35 | if err != nil {
36 | return err
37 | }
38 |
39 | defer func(yamlFile *os.File) {
40 | err := yamlFile.Close()
41 | if err != nil {
42 | g.Log.Fatal(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 interfaces.CmdRunner, globber interfaces.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 | g.Log.Fatal(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 | g.Log.Fatal(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 interfaces.CmdRunner, globber interfaces.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 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 | if err != nil {
182 | return err
183 | }
184 |
185 | return nil
186 | }
187 |
188 | // RenderAppSource uses the Helm CLI to render the templates of a given chart.
189 | // It takes a cmdRunner to run the Helm command, a release name for the Helm release,
190 | // the chart name and version, a temporary directory for storing intermediate files,
191 | // and the target type which categorizes the application.
192 | // The function constructs the Helm command with the provided arguments, runs it, and checks for any errors.
193 | // If there are any errors, it returns them. Otherwise, it returns nil.
194 | func (g RealHelmChartProcessor) RenderAppSource(cmdRunner interfaces.CmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType string) error {
195 | g.Log.Debugf("Rendering [%s] chart's version [%s] templates using release name [%s]",
196 | cyan(chartName),
197 | cyan(chartVersion),
198 | cyan(releaseName))
199 |
200 | _, stderr, err := cmdRunner.Run(
201 | "helm",
202 | "template",
203 | "--release-name", releaseName,
204 | fmt.Sprintf("%s/charts/%s/%s", tmpDir, targetType, chartName),
205 | "--output-dir", fmt.Sprintf("%s/templates/%s", tmpDir, targetType),
206 | "--values", fmt.Sprintf("%s/charts/%s/%s/values.yaml", tmpDir, targetType, chartName),
207 | "--values", fmt.Sprintf("%s/%s-values-%s.yaml", tmpDir, chartName, targetType),
208 | )
209 |
210 | if err != nil {
211 | g.Log.Error(stderr)
212 | return err
213 | }
214 |
215 | return nil
216 | }
217 |
--------------------------------------------------------------------------------
/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 | "testing"
9 |
10 | "github.com/op/go-logging"
11 | "github.com/shini4i/argo-compare/cmd/argo-compare/mocks"
12 | "github.com/shini4i/argo-compare/internal/models"
13 | "github.com/stretchr/testify/assert"
14 | "go.uber.org/mock/gomock"
15 | )
16 |
17 | const (
18 | testsDir = "../../../testdata/disposable"
19 | )
20 |
21 | func TestGenerateValuesFile(t *testing.T) {
22 | helmChartProcessor := RealHelmChartProcessor{}
23 |
24 | // Create a temporary directory
25 | tmpDir, err := os.MkdirTemp(testsDir, "test-")
26 | if err != nil {
27 | t.Fatal(err)
28 | }
29 |
30 | // Delete the directory after the test finishes
31 | defer func(path string) {
32 | err := os.RemoveAll(path)
33 | if err != nil {
34 | t.Fatal(err)
35 | }
36 | }(tmpDir)
37 |
38 | chartName := "ingress-nginx"
39 | targetType := "src"
40 | values := "fullnameOverride: ingress-nginx\ncontroller:\n kind: DaemonSet\n service:\n externalTrafficPolicy: Local\n annotations:\n fancyAnnotation: false\n"
41 |
42 | // Test case 1: Everything works as expected
43 | err = helmChartProcessor.GenerateValuesFile(chartName, tmpDir, targetType, values, nil)
44 | assert.NoError(t, err, "expected no error, got %v", err)
45 |
46 | // Read the generated file
47 | generatedValues, err := os.ReadFile(tmpDir + "/" + chartName + "-values-" + targetType + ".yaml")
48 | if err != nil {
49 | t.Fatal(err)
50 | }
51 |
52 | assert.Equal(t, values, string(generatedValues))
53 |
54 | // Test case 2: Error when creating the file
55 | err = helmChartProcessor.GenerateValuesFile(chartName, "/non/existing/path", targetType, values, nil)
56 | assert.Error(t, err, "expected error, got nil")
57 | }
58 |
59 | func TestDownloadHelmChart(t *testing.T) {
60 | ctrl := gomock.NewController(t)
61 | defer ctrl.Finish()
62 |
63 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")}
64 |
65 | // Create the mocks
66 | mockGlobber := mocks.NewMockGlobber(ctrl)
67 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl)
68 |
69 | // Test case 1: chart exists
70 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{testsDir + "/ingress-nginx-3.34.0.tgz"}, nil)
71 | err := helmChartProcessor.DownloadHelmChart(mockCmdRunner,
72 | mockGlobber,
73 | testsDir+"/cache",
74 | "https://chart.example.com",
75 | "ingress-nginx",
76 | "3.34.0",
77 | []models.RepoCredentials{},
78 | )
79 | assert.NoError(t, err, "expected no error, got %v", err)
80 |
81 | // Test case 2: chart does not exist, and successfully downloaded
82 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{}, nil)
83 | mockCmdRunner.EXPECT().Run("helm",
84 | "pull",
85 | "--destination", gomock.Any(),
86 | "--username", gomock.Any(),
87 | "--password", gomock.Any(),
88 | "--repo", gomock.Any(),
89 | gomock.Any(),
90 | "--version", gomock.Any()).Return("", "", nil)
91 | err = helmChartProcessor.DownloadHelmChart(mockCmdRunner,
92 | mockGlobber,
93 | testsDir+"/cache",
94 | "https://chart.example.com",
95 | "ingress-nginx",
96 | "3.34.0",
97 | []models.RepoCredentials{},
98 | )
99 | assert.NoError(t, err, "expected no error, got %v", err)
100 |
101 | // Test case 3: chart does not exist, and failed to download
102 | osErr := &exec.ExitError{
103 | ProcessState: &os.ProcessState{},
104 | }
105 | mockGlobber.EXPECT().Glob(gomock.Any()).Return([]string{}, nil)
106 | mockCmdRunner.EXPECT().Run("helm",
107 | "pull",
108 | "--destination", gomock.Any(),
109 | "--username", gomock.Any(),
110 | "--password", gomock.Any(),
111 | "--repo", gomock.Any(),
112 | gomock.Any(),
113 | "--version", gomock.Any()).Return("", "dummy error message", osErr)
114 | err = helmChartProcessor.DownloadHelmChart(mockCmdRunner,
115 | mockGlobber,
116 | testsDir+"/cache",
117 | "https://chart.example.com",
118 | "ingress-nginx",
119 | "3.34.0",
120 | []models.RepoCredentials{},
121 | )
122 | assert.ErrorIsf(t, err, FailedToDownloadChart, "expected error %v, got %v", FailedToDownloadChart, err)
123 | }
124 |
125 | func TestExtractHelmChart(t *testing.T) {
126 | ctrl := gomock.NewController(t)
127 | defer ctrl.Finish()
128 |
129 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")}
130 |
131 | // Create the mocks
132 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl)
133 | mockGlobber := mocks.NewMockGlobber(ctrl)
134 |
135 | // Set up the expected behavior for the mocks
136 |
137 | // Test case 1: Single chart file found
138 | expectedChartFileName := testsDir + "/charts/ingress-nginx/ingress-nginx-3.34.0.tgz"
139 | expectedChartLocation := testsDir + "/cache"
140 | expectedTmpDir := testsDir + "/tmp"
141 | expectedTargetType := "target"
142 |
143 | // Mock the behavior of the globber
144 | mockGlobber.EXPECT().Glob(testsDir+"/cache/ingress-nginx-3.34.0*.tgz").Return([]string{expectedChartFileName}, nil)
145 |
146 | // Mock the behavior of the cmdRunner
147 | mockCmdRunner.EXPECT().Run("tar",
148 | "xf",
149 | expectedChartFileName,
150 | "-C",
151 | fmt.Sprintf("%s/charts/%s", expectedTmpDir, expectedTargetType),
152 | ).Return("", "", nil)
153 |
154 | err := helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType)
155 |
156 | assert.NoError(t, err, "expected no error, got %v", err)
157 |
158 | // Test case 2: Multiple chart files found, error expected
159 | expectedChartFilesNames := []string{testsDir + "/charts/sonarqube/sonarqube-4.0.0+315.tgz",
160 | testsDir + "/charts/sonarqube/sonarqube-4.0.0+316.tgz"}
161 |
162 | mockGlobber.EXPECT().Glob(testsDir+"/cache/sonarqube-4.0.0*.tgz").Return(expectedChartFilesNames, nil)
163 |
164 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "sonarqube", "4.0.0", expectedChartLocation, expectedTmpDir, expectedTargetType)
165 | assert.Error(t, err, "expected error, got %v", err)
166 |
167 | // Test case 3: Chart file found, but failed to extract
168 | mockGlobber.EXPECT().Glob(testsDir+"/cache/ingress-nginx-3.34.0*.tgz").Return([]string{expectedChartFileName}, nil)
169 | mockCmdRunner.EXPECT().Run("tar",
170 | "xf",
171 | expectedChartFileName,
172 | "-C",
173 | fmt.Sprintf("%s/charts/%s", expectedTmpDir, expectedTargetType),
174 | ).Return("", "some unexpected error", errors.New("some unexpected error"))
175 |
176 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType)
177 | assert.Error(t, err, "expected error, got %v", err)
178 |
179 | // Test case 4: zglob failed to run
180 | mockGlobber.EXPECT().Glob(testsDir+"/cache/ingress-nginx-3.34.0*.tgz").Return([]string{}, os.ErrPermission)
181 |
182 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType)
183 | assert.Error(t, err, "expected error, got %v", err)
184 |
185 | // Test case 5: Failed to find chart file
186 | mockGlobber.EXPECT().Glob(testsDir+"/cache/ingress-nginx-3.34.0*.tgz").Return([]string{}, nil)
187 |
188 | err = helmChartProcessor.ExtractHelmChart(mockCmdRunner, mockGlobber, "ingress-nginx", "3.34.0", expectedChartLocation, expectedTmpDir, expectedTargetType)
189 | assert.Error(t, err, "expected error, got %v", err)
190 | }
191 |
192 | func TestRenderAppSource(t *testing.T) {
193 | ctrl := gomock.NewController(t)
194 | defer ctrl.Finish()
195 |
196 | helmChartProcessor := RealHelmChartProcessor{Log: logging.MustGetLogger("test")}
197 |
198 | // Create an instance of the mock CmdRunner
199 | mockCmdRunner := mocks.NewMockCmdRunner(ctrl)
200 |
201 | releaseName := "my-release"
202 | chartName := "my-chart"
203 | chartVersion := "1.2.3"
204 | tmpDir := testsDir + "/tmp"
205 | targetType := "src"
206 |
207 | // Test case 1: Successful render
208 | mockCmdRunner.EXPECT().Run("helm",
209 | "template",
210 | "--release-name", gomock.Any(),
211 | gomock.Any(),
212 | "--output-dir", gomock.Any(),
213 | "--values", gomock.Any(),
214 | "--values", gomock.Any()).Return("", "", nil)
215 |
216 | // Call the function under test
217 | err := helmChartProcessor.RenderAppSource(mockCmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType)
218 | assert.NoError(t, err, "expected no error, got %v", err)
219 |
220 | // Test case 2: Failed render
221 | osErr := &exec.ExitError{
222 | ProcessState: &os.ProcessState{},
223 | }
224 | mockCmdRunner.EXPECT().Run("helm",
225 | "template",
226 | "--release-name", gomock.Any(),
227 | gomock.Any(),
228 | "--output-dir", gomock.Any(),
229 | "--values", gomock.Any(),
230 | "--values", gomock.Any()).Return("", "", osErr)
231 |
232 | err = helmChartProcessor.RenderAppSource(mockCmdRunner, releaseName, chartName, chartVersion, tmpDir, targetType)
233 | assert.Errorf(t, err, "expected error, got %v", err)
234 | }
235 |
--------------------------------------------------------------------------------
/cmd/argo-compare/utils/osfs.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "os"
4 |
5 | type RealOsFs struct{}
6 |
7 | func (r *RealOsFs) CreateTemp(dir, pattern string) (f *os.File, err error) {
8 | return os.CreateTemp(dir, pattern)
9 | }
10 |
11 | func (r *RealOsFs) Remove(name string) error {
12 | return os.Remove(name)
13 | }
14 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | ignore:
2 | - "**/*_test.go"
3 | - "cmd/argo-compare/mocks/**"
4 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/shini4i/argo-compare
2 |
3 | go 1.24
4 |
5 | toolchain go1.24.1
6 |
7 | require (
8 | github.com/alecthomas/kong v1.10.0
9 | github.com/codingsince1985/checksum v1.3.0
10 | github.com/fatih/color v1.18.0
11 | github.com/go-git/go-git/v5 v5.15.0
12 | github.com/hexops/gotextdiff v1.0.3
13 | github.com/mattn/go-zglob v0.0.6
14 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
15 | github.com/spf13/afero v1.14.0
16 | github.com/stretchr/testify v1.10.0
17 | go.uber.org/mock v0.5.1
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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
33 | github.com/kevinburke/ssh_config v1.2.0 // indirect
34 | github.com/mattn/go-colorable v0.1.13 // indirect
35 | github.com/mattn/go-isatty v0.0.20 // indirect
36 | github.com/pjbgf/sha1cd v0.3.2 // indirect
37 | github.com/pmezard/go-difflib v1.0.0 // indirect
38 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
39 | github.com/skeema/knownhosts v1.3.1 // indirect
40 | github.com/xanzy/ssh-agent v0.3.3 // indirect
41 | golang.org/x/crypto v0.37.0 // indirect
42 | golang.org/x/net v0.39.0 // indirect
43 | golang.org/x/sys v0.32.0 // indirect
44 | golang.org/x/text v0.24.0 // indirect
45 | gopkg.in/warnings.v0 v0.1.2 // indirect
46 | )
47 |
--------------------------------------------------------------------------------
/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/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
9 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
10 | github.com/alecthomas/kong v1.10.0 h1:8K4rGDpT7Iu+jEXCIJUeKqvpwZHbsFRoebLbnzlmrpw=
11 | github.com/alecthomas/kong v1.10.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
12 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
13 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
14 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
15 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
16 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
17 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
18 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
19 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
20 | github.com/codingsince1985/checksum v1.3.0 h1:kqqIqWBwjidGmt/pO4yXCEX+np7HACGx72EB+MkKcVY=
21 | github.com/codingsince1985/checksum v1.3.0/go.mod h1:QfRskdtdWap+gJil8e5obw6I8/cWJ0SwMUACruWDSU8=
22 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
23 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
27 | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
28 | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
29 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
30 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
31 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
32 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
33 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
34 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
35 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
36 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
37 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
38 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
39 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
40 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
41 | github.com/go-git/go-git/v5 v5.15.0 h1:f5Qn0W0F7ry1iN0ZwIU5m/n7/BKB4hiZfc+zlZx7ly0=
42 | github.com/go-git/go-git/v5 v5.15.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
43 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
44 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
45 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
46 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
47 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
48 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
49 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
50 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
51 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
52 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
53 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
54 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
55 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
56 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
57 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
58 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
59 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
60 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
61 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
62 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
63 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
64 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
65 | github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A=
66 | github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
67 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
68 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
69 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
70 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
71 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
72 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
73 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
74 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
75 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
76 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
77 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
78 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
79 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
80 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
81 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
82 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
83 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
84 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
85 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
86 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
87 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
88 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
89 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
90 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
91 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
92 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
93 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
94 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
95 | go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
96 | go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
97 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
98 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
99 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
100 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
101 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
102 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
103 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
104 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
105 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
106 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
107 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
108 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
109 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
110 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
111 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
112 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
113 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
114 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
115 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
116 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
117 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
118 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
119 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
120 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
121 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
122 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
123 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
124 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
125 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
126 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
127 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
128 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
129 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
130 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
131 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
132 |
--------------------------------------------------------------------------------
/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 | // Contains checks if a string `s` is present in the given string slice `slice`.
24 | // It iterates over each item in the slice and returns true if a match is found.
25 | // If no match is found, it returns false.
26 | func Contains(slice []string, s string) bool {
27 | for _, item := range slice {
28 | if item == s {
29 | return true
30 | }
31 | }
32 | return false
33 | }
34 |
35 | // StripHelmLabels removes the specified Helm labels from the content of a file.
36 | // The function takes a file path as input and returns the stripped file content as a byte slice.
37 | // It removes the labels listed in the `labels` slice using regular expressions.
38 | // The function returns an error if there is an issue reading the file.
39 | func StripHelmLabels(file string) ([]byte, error) {
40 | // list of labels to remove
41 | labels := []string{
42 | "app.kubernetes.io/managed-by",
43 | "helm.sh/chart",
44 | "chart",
45 | "app.kubernetes.io/version",
46 | }
47 |
48 | regex := strings.Join(labels, "|")
49 |
50 | // remove helm labels as they are not needed for comparison
51 | // it might be error-prone, as those labels are not always the same
52 | re := regexp.MustCompile("(?m)[\r\n]+^.*(" + regex + "):.*$")
53 |
54 | var fileData []byte
55 | var err error
56 |
57 | if fileData, err = os.ReadFile(file); err != nil /* #nosec G304 */ {
58 | return nil, err
59 | }
60 |
61 | strippedFileData := re.ReplaceAll(fileData, []byte(""))
62 |
63 | return strippedFileData, nil
64 | }
65 |
66 | // WriteToFile writes the provided data to a file specified by the file path.
67 | // It takes a file path and a byte slice of data as input.
68 | // The function writes the data to the file with the specified file permissions (0644).
69 | // It returns an error if there is an issue writing to the file.
70 | func WriteToFile(fs afero.Fs, file string, data []byte) error {
71 | if err := afero.WriteFile(fs, file, data, 0644); err != nil {
72 | return err
73 | }
74 | return nil
75 | }
76 |
77 | // CreateTempFile creates a temporary file in the "/tmp" directory with a unique name
78 | // that has the prefix "compare-" and suffix ".yaml". It then writes the provided content
79 | // to this temporary file. The function returns a pointer to the created os.File if it
80 | // succeeds. If the function fails at any step, it returns an error wrapped with context
81 | // about what step of the process it failed at.
82 | func CreateTempFile(fs afero.Fs, content string) (afero.File, error) {
83 | tmpFile, err := afero.TempFile(fs, "/tmp", "compare-*.yaml")
84 | if err != nil {
85 | return nil, fmt.Errorf("failed to create temporary file: %w", err)
86 | }
87 |
88 | if err = WriteToFile(fs, tmpFile.Name(), []byte(content)); err != nil {
89 | return nil, fmt.Errorf("failed to write to temporary file: %w", err)
90 | }
91 |
92 | return tmpFile, nil
93 | }
94 |
95 | // FindHelmRepoCredentials scans the provided array of RepoCredentials for a match to the
96 | // provided repository URL, and returns the associated username and password.
97 | // If no matching credentials are found, it returns two empty strings.
98 | func FindHelmRepoCredentials(url string, credentials []models.RepoCredentials) (string, string) {
99 | for _, repoCred := range credentials {
100 | if repoCred.Url == url {
101 | return repoCred.Username, repoCred.Password
102 | }
103 | }
104 | return "", ""
105 | }
106 |
--------------------------------------------------------------------------------
/internal/helpers/helpers_test.go:
--------------------------------------------------------------------------------
1 | package helpers
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/shini4i/argo-compare/internal/models"
7 | "github.com/spf13/afero"
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | const (
12 | expectedStrippedOutput = `# for testing purpose we need only limited fields
13 | apiVersion: apps/v1
14 | kind: Deployment
15 | metadata:
16 | labels:
17 | app.kubernetes.io/instance: traefik-web
18 | app.kubernetes.io/name: traefik
19 | argocd.argoproj.io/instance: traefik
20 | name: traefik
21 | namespace: web
22 | `
23 | )
24 |
25 | func TestGetEnv(t *testing.T) {
26 | // Test case 1: Check if an existing environment variable is retrieved
27 | expectedValue := "test value"
28 | t.Setenv("TEST_KEY", expectedValue)
29 |
30 | actualValue := GetEnv("TEST_KEY", "fallback")
31 | assert.Equal(t, expectedValue, actualValue)
32 |
33 | // Test case 2: Check if a missing environment variable falls back to the default value
34 | expectedValue = "fallback"
35 | actualValue = GetEnv("MISSING_KEY", expectedValue)
36 | assert.Equal(t, expectedValue, actualValue)
37 | }
38 |
39 | func TestContains(t *testing.T) {
40 | // Test case 1: Check if an item is in a slice
41 | slice1 := []string{"apple", "banana", "cherry"}
42 | assert.True(t, Contains(slice1, "apple"))
43 |
44 | // Test case 2: Check if an item is not in a slice
45 | slice2 := []string{"apple", "banana", "cherry"}
46 | assert.False(t, Contains(slice2, "orange"))
47 | }
48 |
49 | func TestStripHelmLabels(t *testing.T) {
50 | // Call the function to strip Helm labels
51 | fileContent, err := StripHelmLabels("../../testdata/dynamic/deployment.yaml")
52 |
53 | assert.NoError(t, err)
54 | assert.Equal(t, expectedStrippedOutput, string(fileContent))
55 |
56 | // We want to be sure that the function returns an error if the file cannot be read
57 | _, err = StripHelmLabels("../../testdata/invalid.yaml")
58 | assert.Error(t, err)
59 | }
60 |
61 | func TestWriteToFile(t *testing.T) {
62 | fs := afero.NewMemMapFs()
63 |
64 | // Test case 1: Check the successful case
65 | filePath := "../../testdata/dynamic/output.txt"
66 |
67 | // Call the function to write data to file
68 | err := WriteToFile(fs, filePath, []byte(expectedStrippedOutput))
69 | assert.NoError(t, err)
70 |
71 | // Read the written file
72 | writtenData, err := afero.ReadFile(fs, filePath)
73 | assert.NoError(t, err)
74 |
75 | // Compare the written data with the test data
76 | assert.Equal(t, expectedStrippedOutput, string(writtenData))
77 |
78 | // Cleanup: Remove the written file
79 | err = fs.Remove(filePath)
80 | assert.NoError(t, err)
81 |
82 | // Test case 2: Check the error case (we should get an error if the file cannot be written)
83 | fs = afero.NewReadOnlyFs(fs)
84 |
85 | filePath = "../../testdata/invalid/output.txt"
86 | err = WriteToFile(fs, filePath, []byte(expectedStrippedOutput))
87 | assert.Error(t, err)
88 | }
89 |
90 | func TestCreateTempFile(t *testing.T) {
91 | t.Run("create and write successful", func(t *testing.T) {
92 | // Create a new in-memory filesystem
93 | fs := afero.NewMemMapFs()
94 |
95 | // Run the function to test
96 | file, err := CreateTempFile(fs, "test content")
97 | assert.NoError(t, err)
98 |
99 | // Check that the file contains the expected content
100 | content, err := afero.ReadFile(fs, file.Name())
101 | assert.NoError(t, err)
102 | assert.Equal(t, "test content", string(content))
103 | })
104 |
105 | t.Run("failed to create file", func(t *testing.T) {
106 | // Create a read-only in-memory filesystem
107 | fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
108 |
109 | // Run the function to test
110 | _, err := CreateTempFile(fs, "test content")
111 |
112 | // assert error to contain the expected message
113 | assert.Contains(t, err.Error(), "failed to create temporary file")
114 | })
115 | }
116 |
117 | func TestFindHelmRepoCredentials(t *testing.T) {
118 | repoCreds := []models.RepoCredentials{
119 | {
120 | Url: "https://charts.example.com",
121 | Username: "user",
122 | Password: "pass",
123 | },
124 | {
125 | Url: "https://charts.test.com",
126 | Username: "testuser",
127 | Password: "testpass",
128 | },
129 | }
130 |
131 | tests := []struct {
132 | name string
133 | url string
134 | expectedUser string
135 | expectedPass string
136 | }{
137 | {
138 | name: "Credentials Found",
139 | url: "https://charts.example.com",
140 | expectedUser: "user",
141 | expectedPass: "pass",
142 | },
143 | {
144 | name: "Credentials Not Found",
145 | url: "https://charts.notfound.com",
146 | expectedUser: "",
147 | expectedPass: "",
148 | },
149 | }
150 |
151 | for _, tt := range tests {
152 | t.Run(tt.name, func(t *testing.T) {
153 | username, password := FindHelmRepoCredentials(tt.url, repoCreds)
154 | assert.Equal(t, tt.expectedUser, username)
155 | assert.Equal(t, tt.expectedPass, password)
156 | })
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/internal/models/application.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | )
7 |
8 | var (
9 | NotApplicationError = errors.New("file is not an Application")
10 | UnsupportedAppConfigurationError = errors.New("unsupported Application configuration")
11 | EmptyFileError = errors.New("file is empty")
12 | )
13 |
14 | type Application struct {
15 | Kind string `yaml:"kind"`
16 | Metadata struct {
17 | Name string `yaml:"name"`
18 | Namespace string `yaml:"namespace"`
19 | } `yaml:"metadata"`
20 | Spec struct {
21 | Source *Source `yaml:"source"`
22 | Sources []*Source `yaml:"sources"`
23 | MultiSource bool `yaml:"-"`
24 | } `yaml:"spec"`
25 | }
26 |
27 | type Source struct {
28 | RepoURL string `yaml:"repoURL"`
29 | Chart string `yaml:"chart,omitempty"`
30 | TargetRevision string `yaml:"targetRevision"`
31 | Path string `yaml:"path,omitempty"`
32 | Helm struct {
33 | ReleaseName string `yaml:"releaseName,omitempty"`
34 | Values string `yaml:"values,omitempty"`
35 | ValueFiles []string `yaml:"valueFiles,omitempty"`
36 | ValuesObject map[string]interface{} `yaml:"valuesObject,omitempty"`
37 | } `yaml:"helm"`
38 | }
39 |
40 | // Validate performs validation checks on the Application struct.
41 | // It checks for the following:
42 | // - If the Application struct is empty, returns EmptyFileError.
43 | // - If both the 'source' and 'sources' fields are set at the same time, returns an error.
44 | // - If the kind of the application is not "Application", returns NotApplicationError.
45 | // - If the application specifies sources, ensures that each source has a non-empty 'chart' field.
46 | // - Sets the 'MultiSource' field to true if sources are specified.
47 | // - Returns nil if all validation checks pass.
48 | func (app *Application) Validate() error {
49 | // Check if the required fields 'Kind', 'Metadata.Name', and 'Metadata.Namespace' are set.
50 | if app.Kind == "" && app.Metadata.Name == "" && app.Metadata.Namespace == "" {
51 | return EmptyFileError
52 | }
53 |
54 | if app.Spec.Source != nil && len(app.Spec.Sources) > 0 {
55 | return fmt.Errorf("both 'source' and 'sources' fields cannot be set at the same time")
56 | }
57 |
58 | if app.Kind != "Application" {
59 | return NotApplicationError
60 | }
61 |
62 | // currently we support only helm repository based charts as a source
63 | if len(app.Spec.Sources) != 0 {
64 | for _, source := range app.Spec.Sources {
65 | if len(source.Chart) == 0 {
66 | return UnsupportedAppConfigurationError
67 | }
68 | }
69 | } else {
70 | if len(app.Spec.Source.Chart) == 0 {
71 | return UnsupportedAppConfigurationError
72 | }
73 | }
74 |
75 | if app.Spec.Sources != nil {
76 | app.Spec.MultiSource = true
77 | }
78 |
79 | return nil
80 | }
81 |
--------------------------------------------------------------------------------
/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 | }{
31 | Source: &Source{
32 | Chart: "", // Empty chart name
33 | },
34 | Sources: nil,
35 | MultiSource: false,
36 | },
37 | }
38 | err = appWithEmptyChart.Validate()
39 | assert.ErrorIs(t, err, UnsupportedAppConfigurationError, "expected UnsupportedAppConfigurationError")
40 |
41 | // Test case 4: Valid application with multiple sources
42 | appWithMultipleSources := &Application{
43 | Kind: "Application",
44 | Spec: struct {
45 | Source *Source `yaml:"source"`
46 | Sources []*Source `yaml:"sources"`
47 | MultiSource bool `yaml:"-"`
48 | }{
49 | Source: nil,
50 | Sources: []*Source{
51 | {
52 | RepoURL: "https://chart.example.com",
53 | Chart: "chart-1",
54 | TargetRevision: "1.0.0",
55 | },
56 | {
57 | RepoURL: "https://chart.example.com",
58 | Chart: "chart-2",
59 | TargetRevision: "2.0.0",
60 | },
61 | },
62 | MultiSource: false,
63 | },
64 | }
65 | err = appWithMultipleSources.Validate()
66 | assert.NoError(t, err, "expected no error")
67 |
68 | // Test case 5: Both 'source' and 'sources' fields are set
69 | appWithBothFields := &Application{
70 | Kind: "Application",
71 | Spec: struct {
72 | Source *Source `yaml:"source"`
73 | Sources []*Source `yaml:"sources"`
74 | MultiSource bool `yaml:"-"`
75 | }{
76 | Source: &Source{
77 | RepoURL: "https://chart.example.com",
78 | Chart: "ingress-nginx",
79 | TargetRevision: "3.34.0",
80 | },
81 | Sources: []*Source{
82 | {
83 | RepoURL: "https://chart.example.com",
84 | Chart: "chart-1",
85 | TargetRevision: "1.0.0",
86 | },
87 | },
88 | MultiSource: false,
89 | },
90 | }
91 | err = appWithBothFields.Validate()
92 | assert.EqualError(t, err, "both 'source' and 'sources' fields cannot be set at the same time", "expected error message")
93 |
94 | // Test case 6: Unsupported app configuration - empty chart name in multiple sources
95 | appWithMultipleSourcesUnsupported := &Application{
96 | Kind: "Application",
97 | Spec: struct {
98 | Source *Source `yaml:"source"`
99 | Sources []*Source `yaml:"sources"`
100 | MultiSource bool `yaml:"-"`
101 | }{
102 | Source: nil,
103 | Sources: []*Source{
104 | {
105 | Chart: "",
106 | },
107 | {
108 | Chart: "chart-2",
109 | },
110 | },
111 | MultiSource: false,
112 | },
113 | }
114 | err = appWithMultipleSourcesUnsupported.Validate()
115 | assert.ErrorIs(t, err, UnsupportedAppConfigurationError, "expected UnsupportedAppConfigurationError")
116 | }
117 |
--------------------------------------------------------------------------------
/internal/models/repo.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type RepoCredentials struct {
4 | Url string `json:"url"`
5 | Username string `json:"username"`
6 | Password string `json:"password"`
7 | }
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/testdata/disposable/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/disposable/.gitkeep
--------------------------------------------------------------------------------
/testdata/dynamic/deployment.yaml:
--------------------------------------------------------------------------------
1 | # for testing purpose we need only limited fields
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | labels:
6 | app.kubernetes.io/instance: traefik-web
7 | app.kubernetes.io/managed-by: Helm
8 | app.kubernetes.io/name: traefik
9 | argocd.argoproj.io/instance: traefik
10 | helm.sh/chart: traefik-23.0.1
11 | name: traefik
12 | namespace: web
13 |
--------------------------------------------------------------------------------
/testdata/repo.git/HEAD:
--------------------------------------------------------------------------------
1 | ref: refs/heads/main
2 |
--------------------------------------------------------------------------------
/testdata/repo.git/config:
--------------------------------------------------------------------------------
1 | [core]
2 | repositoryformatversion = 0
3 | filemode = true
4 | bare = true
5 | ignorecase = true
6 | precomposeunicode = true
7 | [remote "origin"]
8 | url = /Users/ci/git/github.com/shini4i/argo-compare/testdata/repo
9 |
--------------------------------------------------------------------------------
/testdata/repo.git/description:
--------------------------------------------------------------------------------
1 | Unnamed repository; edit this file 'description' to name the repository.
2 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/applypatch-msg.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # An example hook script to check the commit log message taken by
4 | # applypatch from an e-mail message.
5 | #
6 | # The hook should exit with non-zero status after issuing an
7 | # appropriate message if it wants to stop the commit. The hook is
8 | # allowed to edit the commit message file.
9 | #
10 | # To enable this hook, rename this file to "applypatch-msg".
11 |
12 | . git-sh-setup
13 | commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
14 | test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
15 | :
16 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/commit-msg.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # An example hook script to check the commit log message.
4 | # Called by "git commit" with one argument, the name of the file
5 | # that has the commit message. The hook should exit with non-zero
6 | # status after issuing an appropriate message if it wants to stop the
7 | # commit. The hook is allowed to edit the commit message file.
8 | #
9 | # To enable this hook, rename this file to "commit-msg".
10 |
11 | # Uncomment the below to add a Signed-off-by line to the message.
12 | # Doing this in a hook is a bad idea in general, but the prepare-commit-msg
13 | # hook is more suited to it.
14 | #
15 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
16 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
17 |
18 | # This example catches duplicate Signed-off-by lines.
19 |
20 | test "" = "$(grep '^Signed-off-by: ' "$1" |
21 | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
22 | echo >&2 Duplicate Signed-off-by lines.
23 | exit 1
24 | }
25 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/fsmonitor-watchman.sample:
--------------------------------------------------------------------------------
1 | #!/usr/bin/perl
2 |
3 | use strict;
4 | use warnings;
5 | use IPC::Open2;
6 |
7 | # An example hook script to integrate Watchman
8 | # (https://facebook.github.io/watchman/) with git to speed up detecting
9 | # new and modified files.
10 | #
11 | # The hook is passed a version (currently 2) and last update token
12 | # formatted as a string and outputs to stdout a new update token and
13 | # all files that have been modified since the update token. Paths must
14 | # be relative to the root of the working tree and separated by a single NUL.
15 | #
16 | # To enable this hook, rename this file to "query-watchman" and set
17 | # 'git config core.fsmonitor .git/hooks/query-watchman'
18 | #
19 | my ($version, $last_update_token) = @ARGV;
20 |
21 | # Uncomment for debugging
22 | # print STDERR "$0 $version $last_update_token\n";
23 |
24 | # Check the hook interface version
25 | if ($version ne 2) {
26 | die "Unsupported query-fsmonitor hook version '$version'.\n" .
27 | "Falling back to scanning...\n";
28 | }
29 |
30 | my $git_work_tree = get_working_dir();
31 |
32 | my $retry = 1;
33 |
34 | my $json_pkg;
35 | eval {
36 | require JSON::XS;
37 | $json_pkg = "JSON::XS";
38 | 1;
39 | } or do {
40 | require JSON::PP;
41 | $json_pkg = "JSON::PP";
42 | };
43 |
44 | launch_watchman();
45 |
46 | sub launch_watchman {
47 | my $o = watchman_query();
48 | if (is_work_tree_watched($o)) {
49 | output_result($o->{clock}, @{$o->{files}});
50 | }
51 | }
52 |
53 | sub output_result {
54 | my ($clockid, @files) = @_;
55 |
56 | # Uncomment for debugging watchman output
57 | # open (my $fh, ">", ".git/watchman-output.out");
58 | # binmode $fh, ":utf8";
59 | # print $fh "$clockid\n@files\n";
60 | # close $fh;
61 |
62 | binmode STDOUT, ":utf8";
63 | print $clockid;
64 | print "\0";
65 | local $, = "\0";
66 | print @files;
67 | }
68 |
69 | sub watchman_clock {
70 | my $response = qx/watchman clock "$git_work_tree"/;
71 | die "Failed to get clock id on '$git_work_tree'.\n" .
72 | "Falling back to scanning...\n" if $? != 0;
73 |
74 | return $json_pkg->new->utf8->decode($response);
75 | }
76 |
77 | sub watchman_query {
78 | my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
79 | or die "open2() failed: $!\n" .
80 | "Falling back to scanning...\n";
81 |
82 | # In the query expression below we're asking for names of files that
83 | # changed since $last_update_token but not from the .git folder.
84 | #
85 | # To accomplish this, we're using the "since" generator to use the
86 | # recency index to select candidate nodes and "fields" to limit the
87 | # output to file names only. Then we're using the "expression" term to
88 | # further constrain the results.
89 | my $last_update_line = "";
90 | if (substr($last_update_token, 0, 1) eq "c") {
91 | $last_update_token = "\"$last_update_token\"";
92 | $last_update_line = qq[\n"since": $last_update_token,];
93 | }
94 | my $query = <<" END";
95 | ["query", "$git_work_tree", {$last_update_line
96 | "fields": ["name"],
97 | "expression": ["not", ["dirname", ".git"]]
98 | }]
99 | END
100 |
101 | # Uncomment for debugging the watchman query
102 | # open (my $fh, ">", ".git/watchman-query.json");
103 | # print $fh $query;
104 | # close $fh;
105 |
106 | print CHLD_IN $query;
107 | close CHLD_IN;
108 | my $response = do {local $/; };
109 |
110 | # Uncomment for debugging the watch response
111 | # open ($fh, ">", ".git/watchman-response.json");
112 | # print $fh $response;
113 | # close $fh;
114 |
115 | die "Watchman: command returned no output.\n" .
116 | "Falling back to scanning...\n" if $response eq "";
117 | die "Watchman: command returned invalid output: $response\n" .
118 | "Falling back to scanning...\n" unless $response =~ /^\{/;
119 |
120 | return $json_pkg->new->utf8->decode($response);
121 | }
122 |
123 | sub is_work_tree_watched {
124 | my ($output) = @_;
125 | my $error = $output->{error};
126 | if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
127 | $retry--;
128 | my $response = qx/watchman watch "$git_work_tree"/;
129 | die "Failed to make watchman watch '$git_work_tree'.\n" .
130 | "Falling back to scanning...\n" if $? != 0;
131 | $output = $json_pkg->new->utf8->decode($response);
132 | $error = $output->{error};
133 | die "Watchman: $error.\n" .
134 | "Falling back to scanning...\n" if $error;
135 |
136 | # Uncomment for debugging watchman output
137 | # open (my $fh, ">", ".git/watchman-output.out");
138 | # close $fh;
139 |
140 | # Watchman will always return all files on the first query so
141 | # return the fast "everything is dirty" flag to git and do the
142 | # Watchman query just to get it over with now so we won't pay
143 | # the cost in git to look up each individual file.
144 | my $o = watchman_clock();
145 | $error = $output->{error};
146 |
147 | die "Watchman: $error.\n" .
148 | "Falling back to scanning...\n" if $error;
149 |
150 | output_result($o->{clock}, ("/"));
151 | $last_update_token = $o->{clock};
152 |
153 | eval { launch_watchman() };
154 | return 0;
155 | }
156 |
157 | die "Watchman: $error.\n" .
158 | "Falling back to scanning...\n" if $error;
159 |
160 | return 1;
161 | }
162 |
163 | sub get_working_dir {
164 | my $working_dir;
165 | if ($^O =~ 'msys' || $^O =~ 'cygwin') {
166 | $working_dir = Win32::GetCwd();
167 | $working_dir =~ tr/\\/\//;
168 | } else {
169 | require Cwd;
170 | $working_dir = Cwd::cwd();
171 | }
172 |
173 | return $working_dir;
174 | }
175 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/post-update.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # An example hook script to prepare a packed repository for use over
4 | # dumb transports.
5 | #
6 | # To enable this hook, rename this file to "post-update".
7 |
8 | exec git update-server-info
9 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/pre-applypatch.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # An example hook script to verify what is about to be committed
4 | # by applypatch from an e-mail message.
5 | #
6 | # The hook should exit with non-zero status after issuing an
7 | # appropriate message if it wants to stop the commit.
8 | #
9 | # To enable this hook, rename this file to "pre-applypatch".
10 |
11 | . git-sh-setup
12 | precommit="$(git rev-parse --git-path hooks/pre-commit)"
13 | test -x "$precommit" && exec "$precommit" ${1+"$@"}
14 | :
15 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/pre-commit.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # An example hook script to verify what is about to be committed.
4 | # Called by "git commit" with no arguments. The hook should
5 | # exit with non-zero status after issuing an appropriate message if
6 | # it wants to stop the commit.
7 | #
8 | # To enable this hook, rename this file to "pre-commit".
9 |
10 | if git rev-parse --verify HEAD >/dev/null 2>&1
11 | then
12 | against=HEAD
13 | else
14 | # Initial commit: diff against an empty tree object
15 | against=$(git hash-object -t tree /dev/null)
16 | fi
17 |
18 | # If you want to allow non-ASCII filenames set this variable to true.
19 | allownonascii=$(git config --type=bool hooks.allownonascii)
20 |
21 | # Redirect output to stderr.
22 | exec 1>&2
23 |
24 | # Cross platform projects tend to avoid non-ASCII filenames; prevent
25 | # them from being added to the repository. We exploit the fact that the
26 | # printable range starts at the space character and ends with tilde.
27 | if [ "$allownonascii" != "true" ] &&
28 | # Note that the use of brackets around a tr range is ok here, (it's
29 | # even required, for portability to Solaris 10's /usr/bin/tr), since
30 | # the square bracket bytes happen to fall in the designated range.
31 | test $(git diff --cached --name-only --diff-filter=A -z $against |
32 | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
33 | then
34 | cat <<\EOF
35 | Error: Attempt to add a non-ASCII file name.
36 |
37 | This can cause problems if you want to work with people on other platforms.
38 |
39 | To be portable it is advisable to rename the file.
40 |
41 | If you know what you are doing you can disable this check using:
42 |
43 | git config hooks.allownonascii true
44 | EOF
45 | exit 1
46 | fi
47 |
48 | # If there are whitespace errors, print the offending file names and fail.
49 | exec git diff-index --check --cached $against --
50 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/pre-merge-commit.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # An example hook script to verify what is about to be committed.
4 | # Called by "git merge" with no arguments. The hook should
5 | # exit with non-zero status after issuing an appropriate message to
6 | # stderr if it wants to stop the merge commit.
7 | #
8 | # To enable this hook, rename this file to "pre-merge-commit".
9 |
10 | . git-sh-setup
11 | test -x "$GIT_DIR/hooks/pre-commit" &&
12 | exec "$GIT_DIR/hooks/pre-commit"
13 | :
14 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/pre-push.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # An example hook script to verify what is about to be pushed. Called by "git
4 | # push" after it has checked the remote status, but before anything has been
5 | # pushed. If this script exits with a non-zero status nothing will be pushed.
6 | #
7 | # This hook is called with the following parameters:
8 | #
9 | # $1 -- Name of the remote to which the push is being done
10 | # $2 -- URL to which the push is being done
11 | #
12 | # If pushing without using a named remote those arguments will be equal.
13 | #
14 | # Information about the commits which are being pushed is supplied as lines to
15 | # the standard input in the form:
16 | #
17 | #
18 | #
19 | # This sample shows how to prevent push of commits where the log message starts
20 | # with "WIP" (work in progress).
21 |
22 | remote="$1"
23 | url="$2"
24 |
25 | zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing"
48 | exit 1
49 | fi
50 | fi
51 | done
52 |
53 | exit 0
54 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/pre-rebase.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # Copyright (c) 2006, 2008 Junio C Hamano
4 | #
5 | # The "pre-rebase" hook is run just before "git rebase" starts doing
6 | # its job, and can prevent the command from running by exiting with
7 | # non-zero status.
8 | #
9 | # The hook is called with the following parameters:
10 | #
11 | # $1 -- the upstream the series was forked from.
12 | # $2 -- the branch being rebased (or empty when rebasing the current branch).
13 | #
14 | # This sample shows how to prevent topic branches that are already
15 | # merged to 'next' branch from getting rebased, because allowing it
16 | # would result in rebasing already published history.
17 |
18 | publish=next
19 | basebranch="$1"
20 | if test "$#" = 2
21 | then
22 | topic="refs/heads/$2"
23 | else
24 | topic=`git symbolic-ref HEAD` ||
25 | exit 0 ;# we do not interrupt rebasing detached HEAD
26 | fi
27 |
28 | case "$topic" in
29 | refs/heads/??/*)
30 | ;;
31 | *)
32 | exit 0 ;# we do not interrupt others.
33 | ;;
34 | esac
35 |
36 | # Now we are dealing with a topic branch being rebased
37 | # on top of master. Is it OK to rebase it?
38 |
39 | # Does the topic really exist?
40 | git show-ref -q "$topic" || {
41 | echo >&2 "No such branch $topic"
42 | exit 1
43 | }
44 |
45 | # Is topic fully merged to master?
46 | not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
47 | if test -z "$not_in_master"
48 | then
49 | echo >&2 "$topic is fully merged to master; better remove it."
50 | exit 1 ;# we could allow it, but there is no point.
51 | fi
52 |
53 | # Is topic ever merged to next? If so you should not be rebasing it.
54 | only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
55 | only_next_2=`git rev-list ^master ${publish} | sort`
56 | if test "$only_next_1" = "$only_next_2"
57 | then
58 | not_in_topic=`git rev-list "^$topic" master`
59 | if test -z "$not_in_topic"
60 | then
61 | echo >&2 "$topic is already up to date with master"
62 | exit 1 ;# we could allow it, but there is no point.
63 | else
64 | exit 0
65 | fi
66 | else
67 | not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
68 | /usr/bin/perl -e '
69 | my $topic = $ARGV[0];
70 | my $msg = "* $topic has commits already merged to public branch:\n";
71 | my (%not_in_next) = map {
72 | /^([0-9a-f]+) /;
73 | ($1 => 1);
74 | } split(/\n/, $ARGV[1]);
75 | for my $elem (map {
76 | /^([0-9a-f]+) (.*)$/;
77 | [$1 => $2];
78 | } split(/\n/, $ARGV[2])) {
79 | if (!exists $not_in_next{$elem->[0]}) {
80 | if ($msg) {
81 | print STDERR $msg;
82 | undef $msg;
83 | }
84 | print STDERR " $elem->[1]\n";
85 | }
86 | }
87 | ' "$topic" "$not_in_next" "$not_in_master"
88 | exit 1
89 | fi
90 |
91 | <<\DOC_END
92 |
93 | This sample hook safeguards topic branches that have been
94 | published from being rewound.
95 |
96 | The workflow assumed here is:
97 |
98 | * Once a topic branch forks from "master", "master" is never
99 | merged into it again (either directly or indirectly).
100 |
101 | * Once a topic branch is fully cooked and merged into "master",
102 | it is deleted. If you need to build on top of it to correct
103 | earlier mistakes, a new topic branch is created by forking at
104 | the tip of the "master". This is not strictly necessary, but
105 | it makes it easier to keep your history simple.
106 |
107 | * Whenever you need to test or publish your changes to topic
108 | branches, merge them into "next" branch.
109 |
110 | The script, being an example, hardcodes the publish branch name
111 | to be "next", but it is trivial to make it configurable via
112 | $GIT_DIR/config mechanism.
113 |
114 | With this workflow, you would want to know:
115 |
116 | (1) ... if a topic branch has ever been merged to "next". Young
117 | topic branches can have stupid mistakes you would rather
118 | clean up before publishing, and things that have not been
119 | merged into other branches can be easily rebased without
120 | affecting other people. But once it is published, you would
121 | not want to rewind it.
122 |
123 | (2) ... if a topic branch has been fully merged to "master".
124 | Then you can delete it. More importantly, you should not
125 | build on top of it -- other people may already want to
126 | change things related to the topic as patches against your
127 | "master", so if you need further changes, it is better to
128 | fork the topic (perhaps with the same name) afresh from the
129 | tip of "master".
130 |
131 | Let's look at this example:
132 |
133 | o---o---o---o---o---o---o---o---o---o "next"
134 | / / / /
135 | / a---a---b A / /
136 | / / / /
137 | / / c---c---c---c B /
138 | / / / \ /
139 | / / / b---b C \ /
140 | / / / / \ /
141 | ---o---o---o---o---o---o---o---o---o---o---o "master"
142 |
143 |
144 | A, B and C are topic branches.
145 |
146 | * A has one fix since it was merged up to "next".
147 |
148 | * B has finished. It has been fully merged up to "master" and "next",
149 | and is ready to be deleted.
150 |
151 | * C has not merged to "next" at all.
152 |
153 | We would want to allow C to be rebased, refuse A, and encourage
154 | B to be deleted.
155 |
156 | To compute (1):
157 |
158 | git rev-list ^master ^topic next
159 | git rev-list ^master next
160 |
161 | if these match, topic has not merged in next at all.
162 |
163 | To compute (2):
164 |
165 | git rev-list master..topic
166 |
167 | if this is empty, it is fully merged to "master".
168 |
169 | DOC_END
170 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/pre-receive.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # An example hook script to make use of push options.
4 | # The example simply echoes all push options that start with 'echoback='
5 | # and rejects all pushes when the "reject" push option is used.
6 | #
7 | # To enable this hook, rename this file to "pre-receive".
8 |
9 | if test -n "$GIT_PUSH_OPTION_COUNT"
10 | then
11 | i=0
12 | while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
13 | do
14 | eval "value=\$GIT_PUSH_OPTION_$i"
15 | case "$value" in
16 | echoback=*)
17 | echo "echo from the pre-receive-hook: ${value#*=}" >&2
18 | ;;
19 | reject)
20 | exit 1
21 | esac
22 | i=$((i + 1))
23 | done
24 | fi
25 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/prepare-commit-msg.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # An example hook script to prepare the commit log message.
4 | # Called by "git commit" with the name of the file that has the
5 | # commit message, followed by the description of the commit
6 | # message's source. The hook's purpose is to edit the commit
7 | # message file. If the hook fails with a non-zero status,
8 | # the commit is aborted.
9 | #
10 | # To enable this hook, rename this file to "prepare-commit-msg".
11 |
12 | # This hook includes three examples. The first one removes the
13 | # "# Please enter the commit message..." help message.
14 | #
15 | # The second includes the output of "git diff --name-status -r"
16 | # into the message, just before the "git status" output. It is
17 | # commented because it doesn't cope with --amend or with squashed
18 | # commits.
19 | #
20 | # The third example adds a Signed-off-by line to the message, that can
21 | # still be edited. This is rarely a good idea.
22 |
23 | COMMIT_MSG_FILE=$1
24 | COMMIT_SOURCE=$2
25 | SHA1=$3
26 |
27 | /usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
28 |
29 | # case "$COMMIT_SOURCE,$SHA1" in
30 | # ,|template,)
31 | # /usr/bin/perl -i.bak -pe '
32 | # print "\n" . `git diff --cached --name-status -r`
33 | # if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
34 | # *) ;;
35 | # esac
36 |
37 | # SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
38 | # git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
39 | # if test -z "$COMMIT_SOURCE"
40 | # then
41 | # /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
42 | # fi
43 |
--------------------------------------------------------------------------------
/testdata/repo.git/hooks/push-to-checkout.sample:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # An example hook script to update a checked-out tree on a git push.
4 | #
5 | # This hook is invoked by git-receive-pack(1) when it reacts to git
6 | # push and updates reference(s) in its repository, and when the push
7 | # tries to update the branch that is currently checked out and the
8 | # receive.denyCurrentBranch configuration variable is set to
9 | # updateInstead.
10 | #
11 | # By default, such a push is refused if the working tree and the index
12 | # of the remote repository has any difference from the currently
13 | # checked out commit; when both the working tree and the index match
14 | # the current commit, they are updated to match the newly pushed tip
15 | # of the branch. This hook is to be used to override the default
16 | # behaviour; however the code below reimplements the default behaviour
17 | # as a starting point for convenient modification.
18 | #
19 | # The hook receives the commit with which the tip of the current
20 | # branch is going to be updated:
21 | commit=$1
22 |
23 | # It can exit with a non-zero status to refuse the push (when it does
24 | # so, it must not modify the index or the working tree).
25 | die () {
26 | echo >&2 "$*"
27 | exit 1
28 | }
29 |
30 | # Or it can make any necessary changes to the working tree and to the
31 | # index to bring them to the desired state when the tip of the current
32 | # branch is updated to the new commit, and exit with a zero status.
33 | #
34 | # For example, the hook can simply run git read-tree -u -m HEAD "$1"
35 | # in order to emulate git fetch that is run in the reverse direction
36 | # with git push, as the two-tree form of git read-tree -u -m is
37 | # essentially the same as git switch or git checkout that switches
38 | # branches while keeping the local changes in the working tree that do
39 | # not interfere with the difference between the branches.
40 |
41 | # The below is a more-or-less exact translation to shell of the C code
42 | # for the default behaviour for git's push-to-checkout hook defined in
43 | # the push_to_deploy() function in builtin/receive-pack.c.
44 | #
45 | # Note that the hook will be executed from the repository directory,
46 | # not from the working tree, so if you want to perform operations on
47 | # the working tree, you will have to adapt your code accordingly, e.g.
48 | # by adding "cd .." or using relative paths.
49 |
50 | if ! git update-index -q --ignore-submodules --refresh
51 | then
52 | die "Up-to-date check failed"
53 | fi
54 |
55 | if ! git diff-files --quiet --ignore-submodules --
56 | then
57 | die "Working directory has unstaged changes"
58 | fi
59 |
60 | # This is a rough translation of:
61 | #
62 | # head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
63 | if git cat-file -e HEAD 2>/dev/null
64 | then
65 | head=HEAD
66 | else
67 | head=$(git hash-object -t tree --stdin &2
35 | echo " (if you want, you could supply GIT_DIR then run" >&2
36 | echo " $0 [ )" >&2
37 | exit 1
38 | fi
39 |
40 | if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
41 | echo "usage: $0 ][ " >&2
42 | exit 1
43 | fi
44 |
45 | # --- Config
46 | allowunannotated=$(git config --type=bool hooks.allowunannotated)
47 | allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
48 | denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
49 | allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
50 | allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
51 |
52 | # check for no description
53 | projectdesc=$(sed -e '1q' "$GIT_DIR/description")
54 | case "$projectdesc" in
55 | "Unnamed repository"* | "")
56 | echo "*** Project description file hasn't been set" >&2
57 | exit 1
58 | ;;
59 | esac
60 |
61 | # --- Check types
62 | # if $newrev is 0000...0000, it's a commit to delete a ref.
63 | zero=$(git hash-object --stdin &2
76 | echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
77 | exit 1
78 | fi
79 | ;;
80 | refs/tags/*,delete)
81 | # delete tag
82 | if [ "$allowdeletetag" != "true" ]; then
83 | echo "*** Deleting a tag is not allowed in this repository" >&2
84 | exit 1
85 | fi
86 | ;;
87 | refs/tags/*,tag)
88 | # annotated tag
89 | if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
90 | then
91 | echo "*** Tag '$refname' already exists." >&2
92 | echo "*** Modifying a tag is not allowed in this repository." >&2
93 | exit 1
94 | fi
95 | ;;
96 | refs/heads/*,commit)
97 | # branch
98 | if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
99 | echo "*** Creating a branch is not allowed in this repository" >&2
100 | exit 1
101 | fi
102 | ;;
103 | refs/heads/*,delete)
104 | # delete branch
105 | if [ "$allowdeletebranch" != "true" ]; then
106 | echo "*** Deleting a branch is not allowed in this repository" >&2
107 | exit 1
108 | fi
109 | ;;
110 | refs/remotes/*,commit)
111 | # tracking branch
112 | ;;
113 | refs/remotes/*,delete)
114 | # delete tracking branch
115 | if [ "$allowdeletebranch" != "true" ]; then
116 | echo "*** Deleting a tracking branch is not allowed in this repository" >&2
117 | exit 1
118 | fi
119 | ;;
120 | *)
121 | # Anything else (is there anything else?)
122 | echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
123 | exit 1
124 | ;;
125 | esac
126 |
127 | # --- Finished
128 | exit 0
129 |
--------------------------------------------------------------------------------
/testdata/repo.git/info/exclude:
--------------------------------------------------------------------------------
1 | # git ls-files --others --exclude-from=.git/info/exclude
2 | # Lines that start with '#' are comments.
3 | # For a project mostly in C, the following would be a good set of
4 | # exclude patterns (uncomment them if you want to use them):
5 | # *.[oa]
6 | # *~
7 |
--------------------------------------------------------------------------------
/testdata/repo.git/objects/14/4ee8cf8a2bc327c05e5478f46c8b4d867c2d53:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/14/4ee8cf8a2bc327c05e5478f46c8b4d867c2d53
--------------------------------------------------------------------------------
/testdata/repo.git/objects/41/334bbafb63ec09623eb5e970ef98d39bb3840f:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/41/334bbafb63ec09623eb5e970ef98d39bb3840f
--------------------------------------------------------------------------------
/testdata/repo.git/objects/42/fa0288a96d4c0c0ed530f982f13b31fde03da6:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/42/fa0288a96d4c0c0ed530f982f13b31fde03da6
--------------------------------------------------------------------------------
/testdata/repo.git/objects/5e/c56f7d7663cfd5f0b5b85dfdd46ef5addf9fe0:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/5e/c56f7d7663cfd5f0b5b85dfdd46ef5addf9fe0
--------------------------------------------------------------------------------
/testdata/repo.git/objects/61/95e9b8bde1e012ccf756216fa225d35fa33690:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/61/95e9b8bde1e012ccf756216fa225d35fa33690
--------------------------------------------------------------------------------
/testdata/repo.git/objects/63/988c89a49d0b3a2afa0764ef4ea8e76d501b46:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/63/988c89a49d0b3a2afa0764ef4ea8e76d501b46
--------------------------------------------------------------------------------
/testdata/repo.git/objects/8f/c4774c9313d27406df5e3ff71fa4ec68c0deb7:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/8f/c4774c9313d27406df5e3ff71fa4ec68c0deb7
--------------------------------------------------------------------------------
/testdata/repo.git/objects/93/9b376ec8b6af2ba467bd0ca7d57499159b3ba3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/93/9b376ec8b6af2ba467bd0ca7d57499159b3ba3
--------------------------------------------------------------------------------
/testdata/repo.git/objects/b8/5e290803976b2a52702a0a699706f6e4c4ed86:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/b8/5e290803976b2a52702a0a699706f6e4c4ed86
--------------------------------------------------------------------------------
/testdata/repo.git/objects/bd/a469dd11cdfa8cc28bc7e044c360737327e3ce:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/bd/a469dd11cdfa8cc28bc7e044c360737327e3ce
--------------------------------------------------------------------------------
/testdata/repo.git/objects/fc/a984329cd120e7b7fe648cb534a2add7291d41:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shini4i/argo-compare/e20f46c8ddfef69ef59bccc300778228a00cfb46/testdata/repo.git/objects/fc/a984329cd120e7b7fe648cb534a2add7291d41
--------------------------------------------------------------------------------
/testdata/repo.git/packed-refs:
--------------------------------------------------------------------------------
1 | # pack-refs with: peeled fully-peeled sorted
2 | 144ee8cf8a2bc327c05e5478f46c8b4d867c2d53 refs/heads/feature-branch
3 | 5ec56f7d7663cfd5f0b5b85dfdd46ef5addf9fe0 refs/heads/main
4 |
--------------------------------------------------------------------------------
/testdata/test-values.yaml:
--------------------------------------------------------------------------------
1 | fullnameOverride: ingress-nginx
2 | controller:
3 | kind: DaemonSet
4 | service:
5 | externalTrafficPolicy: Local
6 | annotations:
7 | fancyAnnotation: false
8 |
--------------------------------------------------------------------------------
/testdata/test.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: argoproj.io/v1alpha1
2 | kind: Application
3 | metadata:
4 | name: ingress-nginx
5 | namespace: argo-cd
6 | finalizers:
7 | - resources-finalizer.argocd.argoproj.io
8 | spec:
9 | project: default
10 | source:
11 | repoURL: https://kubernetes.github.io/ingress-nginx
12 | chart: ingress-nginx
13 | targetRevision: "4.2.3"
14 | helm:
15 | values: |
16 | fullnameOverride: ingress-nginx
17 | controller:
18 | kind: DaemonSet
19 | service:
20 | externalTrafficPolicy: Local
21 | annotations:
22 | fancyAnnotation: false
23 |
24 | destination:
25 | server: https://kubernetes.default.svc
26 | namespace: web
27 |
28 | syncPolicy:
29 | automated:
30 | prune: true
31 | selfHeal: true
32 | syncOptions:
33 | - CreateNamespace=true
34 |
--------------------------------------------------------------------------------
/testdata/test2.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: argoproj.io/v1alpha1
2 | kind: Application
3 | metadata:
4 | name: ingress-nginx
5 | namespace: argo-cd
6 | finalizers:
7 | - resources-finalizer.argocd.argoproj.io
8 | spec:
9 | project: default
10 | sources:
11 | - repoURL: https://charts.appscode.com/stable
12 | chart: kubed
13 | targetRevision: "v0.13.2"
14 | helm:
15 | values: |
16 | fullnameOverride: kubed
17 | enableAnalytics: false
18 | config:
19 | clusterName: example
20 | - repoURL: https://bitnami-labs.github.io/sealed-secrets
21 | chart: sealed-secrets
22 | targetRevision: "2.10.0"
23 | helm:
24 | values: |
25 | fullnameOverride: sealed-secrets
26 | args: ["--key-renew-period=0", "--update-status"]
27 |
28 | destination:
29 | server: https://kubernetes.default.svc
30 | namespace: web
31 |
32 | syncPolicy:
33 | automated:
34 | prune: true
35 | selfHeal: true
36 | syncOptions:
37 | - CreateNamespace=true
38 |
--------------------------------------------------------------------------------
]