├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── generate-diff.yml │ ├── mkdocs.yaml │ ├── pr.yml │ ├── pre-release-binary.yml │ ├── pre-release-docker.yml │ ├── release-binary.yaml │ └── release-docker.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── argocd-config ├── values-override.yaml └── values.yaml ├── cmd ├── main.go └── options.go ├── docs ├── application-selection.md ├── assets │ ├── ArgoConLogoOrange.svg │ ├── article-banner.png │ ├── example-1.png │ ├── example-2.png │ ├── example-3.png │ ├── flow.png │ ├── flow_dark.png │ ├── linkedin-banner.png │ ├── logo-head.png │ ├── logo.png │ ├── speed_graph.png │ └── versions.css ├── custom-argo-cd-installation.md ├── demo.md ├── faq.md ├── generated-applications.md ├── github-actions-workflow.md ├── gitlab-cicd.md ├── ignoring-lines.md ├── index.md ├── installation.md ├── options.md ├── self-hosted-gh-runner.md └── troubleshooting.md ├── examples ├── basic-appset │ └── my-app-set.yaml ├── custom-target-revision │ ├── app │ │ └── app.yaml │ └── resources │ │ └── deployment.yaml ├── destination-server │ └── app.yaml ├── duplicate-names │ ├── app │ │ ├── app-set-1.yaml │ │ └── app-set-2.yaml │ └── resources │ │ └── deployment.yaml ├── external-chart │ └── nginx.yaml ├── git-generator │ ├── app │ │ └── app-set.yaml │ └── resources │ │ ├── folder1 │ │ └── deployment.yaml │ │ └── folder2 │ │ └── deployment.yaml ├── helm │ ├── charts │ │ └── myApp │ │ │ ├── .helmignore │ │ │ ├── Chart.yaml │ │ │ ├── templates │ │ │ ├── NOTES.txt │ │ │ ├── _helpers.tpl │ │ │ ├── deployment.yaml │ │ │ ├── hpa.yaml │ │ │ ├── ingress.yaml │ │ │ ├── service.yaml │ │ │ ├── serviceaccount.yaml │ │ │ └── tests │ │ │ │ └── test-connection.yaml │ │ │ └── values.yaml │ └── values │ │ ├── filtered.yaml │ │ ├── my-app-set.yaml │ │ └── my-app.yaml ├── ignore-annotation │ └── app.yaml ├── internal-chart │ └── app.yaml ├── kustomize │ ├── applications │ │ ├── my-service-prod.yaml │ │ └── my-service-staging.yaml │ └── my-service │ │ ├── base │ │ ├── deployment.yaml │ │ ├── kustomization.yaml │ │ └── service.yaml │ │ └── overlay │ │ ├── production │ │ ├── kustomization.yaml │ │ ├── memory_limit.yaml │ │ └── replica_count.yaml │ │ └── staging │ │ ├── kustomization.yaml │ │ └── memory_limit.yaml ├── label-selectors │ └── app.yaml ├── list-generator │ ├── app │ │ ├── app-set-empty.yaml │ │ └── app-set.yaml │ └── resources │ │ └── deployment.yaml ├── manifest-generate-paths │ ├── file-not-exist.yaml │ └── valid-annotation.yaml └── watch-pattern │ └── valid-regex.yaml ├── go.mod ├── go.sum ├── makefile ├── mkdocs.yml ├── pkg ├── argoapplication │ ├── application_sets.go │ ├── applications.go │ ├── conversion.go │ ├── conversion_test.go │ ├── filter.go │ ├── filter_test.go │ ├── kind.go │ ├── patching.go │ ├── patching_test.go │ ├── unique.go │ └── unique_test.go ├── argocd │ ├── argocd.go │ └── utils.go ├── cluster │ └── types.go ├── diff │ ├── diff.go │ ├── diff_test.go │ ├── format.go │ ├── format_test.go │ ├── generator.go │ ├── html.go │ ├── infobox.go │ ├── markdown.go │ ├── no_apps_found.go │ └── summary.go ├── duplicates │ ├── duplicates.go │ └── utils.go ├── extract │ └── extract.go ├── git │ ├── branch.go │ └── branch_test.go ├── k3d │ └── k3d.go ├── k8s │ ├── parser.go │ ├── parser_test.go │ ├── resource.go │ └── resource_test.go ├── kind │ └── kind.go ├── minikube │ └── minikube.go ├── selector │ ├── selector.go │ └── selector_test.go └── utils │ ├── k8s-client.go │ ├── k8s.go │ └── utils.go └── tests ├── integration-test ├── branch-1 │ └── target │ │ └── output.md ├── branch-2 │ └── target │ │ └── output.md ├── branch-3 │ └── target │ │ └── output.md ├── branch-4 │ └── target │ │ └── output.md ├── branch-5 │ ├── target-1 │ │ └── output.md │ ├── target-2 │ │ └── output.md │ ├── target-3 │ │ └── output.md │ ├── target-4 │ │ └── output.md │ ├── target-5 │ │ └── output.md │ ├── target-6 │ │ └── output.md │ └── target-7 │ │ └── output.md ├── branch-6 │ └── target │ │ └── output.md ├── branch-7 │ └── target │ │ └── output.md └── branch-8 │ └── target │ └── output.md ├── kind-config └── options.yaml └── makefile /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'Bug | some title ' 5 | labels: '' 6 | assignees: dag-andersen 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | ... 12 | 13 | ### Expected behavior 14 | ... 15 | 16 | ### Standard output (with `--debug` flag) 17 | ``` 18 | ... 19 | 🤖 Getting resources from branch (branch: some/branch) 20 | ❌ Failed to get manifests for application: xxx, error: ... 21 | ❌ Failed to process application: XXXX 22 | ❌ Failed to extract resources 23 | 💥 Deleting cluster... 24 | 💥 Cluster deleted successfully 25 | ❌ failed to get resources: failed to process applications 26 | ``` 27 | 28 | ### Application causing problems (if applicable) 29 | ```yaml 30 | apiVersion: argoproj.io/v1alpha1 31 | kind: ApplicationSet 32 | metadata: 33 | name: git-generator-example-appset 34 | spec: 35 | goTemplate: true 36 | goTemplateOptions: ["missingkey=error"] 37 | generators: 38 | - git: 39 | repoURL: https://github.com/dag-andersen/argocd-diff-preview.git 40 | revision: HEAD 41 | directories: 42 | - path: examples/git-generator/resources/** 43 | values: 44 | name: "{{ index .path.segments 3 }}" 45 | template: 46 | ... 47 | ``` 48 | 49 | 50 | ### Parameters 51 | ``` 52 | ✨ Running with: 53 | ✨ - local-cluster-tool: kind 54 | ✨ - cluster-name: argocd-diff-preview 55 | ✨ - kind-options: --config ./kind-config/options.yaml 56 | ✨ - base-branch: integration-test/branch-3/base 57 | ✨ - target-branch: integration-test/branch-3/target 58 | ✨ - secrets-folder: ./secrets 59 | ✨ - output-folder: ./output 60 | ✨ - argocd-namespace: argocd 61 | ✨ - repo: dag-andersen/argocd-diff-preview 62 | ✨ - timeout: 90 seconds 63 | ... 64 | ``` 65 | 66 | ### Your pipeline (if applicable) 67 | ```yaml 68 | jobs: 69 | render-diff: 70 | ... 71 | steps: 72 | - uses: actions/checkout@v4 73 | with: 74 | path: pull-request 75 | 76 | - uses: actions/checkout@v4 77 | with: 78 | ref: main 79 | path: main 80 | 81 | - name: Generate Diff 82 | run: | 83 | docker run \ 84 | --network=host \ 85 | -v /var/run/docker.sock:/var/run/docker.sock \ 86 | -v $(pwd)/main:/base-branch \ 87 | -v $(pwd)/pull-request:/target-branch \ 88 | -v $(pwd)/output:/output \ 89 | -e TARGET_BRANCH=refs/pull/${{ github.event.number }}/merge \ 90 | -e REPO=${{ github.repository }} \ 91 | dagandersen/argocd-diff-preview:vX.X.X 92 | 93 | - name: Post diff as comment 94 | run: | 95 | gh pr comment ${{ github.event.number }} --repo ${{ github.repository }} --body-file output/diff.md --edit-last || \ 96 | gh pr comment ${{ github.event.number }} --repo ${{ github.repository }} --body-file output/diff.md 97 | env: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 99 | 100 | ``` 101 | -------------------------------------------------------------------------------- /.github/workflows/generate-diff.yml: -------------------------------------------------------------------------------- 1 | name: Generate Diff 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "main" 7 | paths: 8 | - ".github/workflows/generate-diff.yml" 9 | - "examples/**" 10 | 11 | jobs: 12 | render-diff: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | pull-requests: write 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | path: pull-request 22 | 23 | - uses: actions/checkout@v4 24 | with: 25 | ref: main 26 | path: main 27 | 28 | - name: Generate Diff 29 | run: | 30 | docker run \ 31 | --network=host \ 32 | -v /var/run/docker.sock:/var/run/docker.sock \ 33 | -v $(pwd)/main:/base-branch \ 34 | -v $(pwd)/pull-request:/target-branch \ 35 | -v $(pwd)/output:/output \ 36 | -e TARGET_BRANCH=refs/pull/${{ github.event.number }}/merge \ 37 | -e REPO=${{ github.repository }} \ 38 | dagandersen/argocd-diff-preview:v0.1.9 39 | 40 | - name: Post diff as comment 41 | run: | 42 | gh pr comment ${{ github.event.number }} --repo ${{ github.repository }} --body-file output/diff.md --edit-last || \ 43 | gh pr comment ${{ github.event.number }} --repo ${{ github.repository }} --body-file output/diff.md 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /.github/workflows/mkdocs.yaml: -------------------------------------------------------------------------------- 1 | name: mkdocs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | paths: 8 | - "docs/**" 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Configure Git Credentials 19 | run: | 20 | git config user.name github-actions[bot] 21 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: 3.x 25 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 26 | - uses: actions/cache@v4 27 | with: 28 | key: mkdocs-material-${{ env.cache_id }} 29 | path: .cache 30 | restore-keys: | 31 | mkdocs-material- 32 | - run: pip install mkdocs-material 33 | - run: mkdocs gh-deploy --force -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "main" 7 | paths: 8 | - "cmd/**" 9 | - "pkg/**" 10 | - "go.mod" 11 | - "go.sum" 12 | - Dockerfile 13 | - .github/workflows/pr.yml 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v4 24 | with: 25 | go-version: '1.24.0' 26 | 27 | - name: Install golangci-lint 28 | run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.5 29 | 30 | - name: Run lint 31 | run: make run-lint 32 | 33 | test: 34 | runs-on: ubuntu-latest 35 | needs: lint 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | 40 | - name: Set up Go 41 | uses: actions/setup-go@v4 42 | with: 43 | go-version: '1.24.0' 44 | 45 | - name: Run unit tests 46 | run: make run-unit-tests GO_TEST_FLAGS="-mod=readonly" 47 | 48 | build: 49 | runs-on: ubuntu-latest 50 | needs: test # This ensures tests must pass before building 51 | strategy: 52 | fail-fast: true 53 | matrix: 54 | # platform: [amd64, arm64] // Takes too long to run both 55 | platform: [amd64] 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | - name: Set up QEMU 60 | uses: docker/setup-qemu-action@v3 61 | - name: Set up Docker Buildx 62 | uses: docker/setup-buildx-action@v3 63 | - name: Build and push 64 | uses: docker/build-push-action@v6 65 | with: 66 | context: . 67 | push: false 68 | platforms: linux/${{ matrix.platform }} 69 | tags: test-${{ matrix.platform }} 70 | build-args: | 71 | VERSION="pr-${{ github.event.pull_request.number }}" 72 | COMMIT=${{ github.sha }} 73 | BUILD_DATE="unknown" 74 | cache-from: type=gha 75 | cache-to: type=gha,mode=max 76 | -------------------------------------------------------------------------------- /.github/workflows/pre-release-binary.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release Binary Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*-pre-release" 7 | 8 | env: 9 | BINARY_NAME: argocd-diff-preview 10 | GITHUB_TOKEN: ${{ github.token }} 11 | 12 | jobs: 13 | build: 14 | name: ${{ matrix.platform.os_name }} with Go 15 | runs-on: ${{ matrix.platform.os }} 16 | permissions: 17 | contents: write 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | platform: 22 | - os_name: Linux-x86_64 23 | os: ubuntu-24.04 24 | goos: linux 25 | goarch: amd64 26 | name: argocd-diff-preview-Linux-x86_64.tar.gz 27 | - os_name: Linux-aarch64 28 | os: ubuntu-24.04 29 | goos: linux 30 | goarch: arm64 31 | name: argocd-diff-preview-Linux-aarch64.tar.gz 32 | - os_name: macOS-x86_64 33 | os: macOS-latest 34 | goos: darwin 35 | goarch: amd64 36 | name: argocd-diff-preview-Darwin-x86_64.tar.gz 37 | - os_name: macOS-aarch64 38 | os: macOS-latest 39 | goos: darwin 40 | goarch: arm64 41 | name: argocd-diff-preview-Darwin-aarch64.tar.gz 42 | steps: 43 | - uses: actions/checkout@v4 44 | with: 45 | fetch-depth: 0 # Fetch all history for all branches and tags 46 | - name: Set up Go 47 | uses: actions/setup-go@v5 48 | with: 49 | go-version: '1.21' 50 | - name: Set env 51 | run: | 52 | VERSION=${GITHUB_REF#refs/tags/} 53 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 54 | SHA=$(git rev-parse --short HEAD) 55 | echo "SHA=${SHA}" >> $GITHUB_ENV 56 | BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 57 | echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV 58 | - name: Build binary 59 | env: 60 | GOOS: ${{ matrix.platform.goos }} 61 | GOARCH: ${{ matrix.platform.goarch }} 62 | run: | 63 | go build -v -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${SHA}' -X 'main.BuildDate=${BUILD_DATE}'" -o ${{ env.BINARY_NAME }} ./cmd 64 | - name: Package as archive 65 | shell: bash 66 | run: | 67 | tar czvf ${{ matrix.platform.name }} ${{ env.BINARY_NAME }} 68 | - name: Publish release artifacts 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: argocd-diff-preview-${{ matrix.platform.os_name }}-${{ env.VERSION }}-${{ env.SHA }} 72 | path: "argocd-diff-preview-*" 73 | - name: Publish GitHub release 74 | uses: softprops/action-gh-release@v2 75 | with: 76 | draft: false 77 | prerelease: true 78 | files: "argocd-diff-preview-*" 79 | -------------------------------------------------------------------------------- /.github/workflows/pre-release-docker.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release Docker Images 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*-pre-release" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | platform: [amd64, arm64] 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # Fetch all history for all branches and tags 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v3 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v3 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | - name: Set env 30 | run: | 31 | VERSION=${GITHUB_REF#refs/tags/} 32 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 33 | SHA=$(git rev-parse --short HEAD) 34 | echo "SHA=${SHA}" >> $GITHUB_ENV 35 | BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 36 | echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV 37 | - name: Build and push 38 | uses: docker/build-push-action@v6 39 | with: 40 | context: . 41 | file: Dockerfile 42 | push: true 43 | platforms: linux/${{ matrix.platform }} 44 | build-args: | 45 | VERSION=${{ env.VERSION }} 46 | COMMIT=${{ env.SHA }} 47 | BUILD_DATE=${{ env.BUILD_DATE }} 48 | tags: dagandersen/argocd-diff-preview:${{ env.VERSION }}-${{ env.SHA }}-${{ matrix.platform }} 49 | cache-from: type=gha 50 | cache-to: type=gha,mode=max 51 | 52 | create-manifest: 53 | runs-on: ubuntu-latest 54 | needs: [build] 55 | steps: 56 | - name: Checkout 57 | uses: actions/checkout@v4 58 | with: 59 | fetch-depth: 0 # Fetch all history for all branches and tags 60 | - name: Set up QEMU 61 | uses: docker/setup-qemu-action@v3 62 | - name: Set up Docker Buildx 63 | uses: docker/setup-buildx-action@v3 64 | - name: Login to Docker Hub 65 | uses: docker/login-action@v3 66 | with: 67 | username: ${{ secrets.DOCKERHUB_USERNAME }} 68 | password: ${{ secrets.DOCKERHUB_TOKEN }} 69 | - name: Set env 70 | run: | 71 | VERSION=${GITHUB_REF#refs/tags/} 72 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 73 | SHA=$(git rev-parse --short HEAD) 74 | echo "SHA=${SHA}" >> $GITHUB_ENV 75 | - name: create manifest 76 | run: | 77 | docker buildx imagetools create -t \ 78 | dagandersen/argocd-diff-preview:${{ env.VERSION }}-${{ env.SHA }} \ 79 | dagandersen/argocd-diff-preview:${{ env.VERSION }}-${{ env.SHA }}-amd64 \ 80 | dagandersen/argocd-diff-preview:${{ env.VERSION }}-${{ env.SHA }}-arm64 -------------------------------------------------------------------------------- /.github/workflows/release-binary.yaml: -------------------------------------------------------------------------------- 1 | name: Release Binary 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | - "!v*.*.*-pre-release" 8 | 9 | env: 10 | BINARY_NAME: argocd-diff-preview 11 | GITHUB_TOKEN: ${{ github.token }} 12 | 13 | jobs: 14 | build: 15 | name: ${{ matrix.platform.os_name }} with Go 16 | runs-on: ${{ matrix.platform.os }} 17 | permissions: 18 | contents: write 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | platform: 23 | - os_name: Linux-x86_64 24 | os: ubuntu-24.04 25 | goos: linux 26 | goarch: amd64 27 | name: argocd-diff-preview-Linux-x86_64.tar.gz 28 | - os_name: Linux-aarch64 29 | os: ubuntu-24.04 30 | goos: linux 31 | goarch: arm64 32 | name: argocd-diff-preview-Linux-aarch64.tar.gz 33 | - os_name: macOS-x86_64 34 | os: macOS-latest 35 | goos: darwin 36 | goarch: amd64 37 | name: argocd-diff-preview-Darwin-x86_64.tar.gz 38 | - os_name: macOS-aarch64 39 | os: macOS-latest 40 | goos: darwin 41 | goarch: arm64 42 | name: argocd-diff-preview-Darwin-aarch64.tar.gz 43 | steps: 44 | - uses: actions/checkout@v4 45 | with: 46 | fetch-depth: 0 # Fetch all history for all branches and tags 47 | - name: Set up Go 48 | uses: actions/setup-go@v5 49 | with: 50 | go-version: '1.21' 51 | - name: Set env 52 | run: | 53 | VERSION=${GITHUB_REF#refs/tags/} 54 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 55 | SHA=$(git rev-parse --short HEAD) 56 | echo "SHA=${SHA}" >> $GITHUB_ENV 57 | BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 58 | echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV 59 | - name: Build binary 60 | env: 61 | GOOS: ${{ matrix.platform.goos }} 62 | GOARCH: ${{ matrix.platform.goarch }} 63 | run: | 64 | go build -v -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${SHA}' -X 'main.BuildDate=${BUILD_DATE}'" -o ${{ env.BINARY_NAME }} ./cmd 65 | - name: Package as archive 66 | shell: bash 67 | run: | 68 | tar czvf ${{ matrix.platform.name }} ${{ env.BINARY_NAME }} 69 | - name: Publish release artifacts 70 | uses: actions/upload-artifact@v4 71 | with: 72 | name: argocd-diff-preview-${{ matrix.platform.os_name }} 73 | path: "argocd-diff-preview-*" 74 | - name: Publish GitHub release 75 | uses: softprops/action-gh-release@v2 76 | with: 77 | draft: true 78 | prerelease: false 79 | files: "argocd-diff-preview-*" 80 | -------------------------------------------------------------------------------- /.github/workflows/release-docker.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Images 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | - "!v*.*.*-pre-release" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | platform: [amd64, arm64] 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v3 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | - name: Login to Docker Hub 24 | uses: docker/login-action@v3 25 | with: 26 | username: ${{ secrets.DOCKERHUB_USERNAME }} 27 | password: ${{ secrets.DOCKERHUB_TOKEN }} 28 | - name: Set env 29 | run: | 30 | VERSION=${GITHUB_REF#refs/tags/} 31 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 32 | SHA=$(git rev-parse --short HEAD) 33 | echo "SHA=${SHA}" >> $GITHUB_ENV 34 | BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 35 | echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV 36 | - name: Build and push 37 | uses: docker/build-push-action@v6 38 | with: 39 | context: . 40 | file: Dockerfile 41 | push: true 42 | platforms: linux/${{ matrix.platform }} 43 | build-args: | 44 | VERSION=${{ env.VERSION }} 45 | COMMIT=${{ env.SHA }} 46 | BUILD_DATE=${{ env.BUILD_DATE }} 47 | tags: dagandersen/argocd-diff-preview:${{ env.VERSION }}-${{ matrix.platform }} 48 | cache-from: type=gha 49 | cache-to: type=gha,mode=max 50 | 51 | create-manifest: 52 | runs-on: ubuntu-latest 53 | needs: [build] 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | - name: Set up QEMU 58 | uses: docker/setup-qemu-action@v3 59 | - name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v3 61 | - name: Login to Docker Hub 62 | uses: docker/login-action@v3 63 | with: 64 | username: ${{ secrets.DOCKERHUB_USERNAME }} 65 | password: ${{ secrets.DOCKERHUB_TOKEN }} 66 | - name: Set env 67 | run: echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 68 | - name: create manifest 69 | run: | 70 | docker buildx imagetools create -t \ 71 | dagandersen/argocd-diff-preview:${VERSION} \ 72 | dagandersen/argocd-diff-preview:${VERSION}-amd64 \ 73 | dagandersen/argocd-diff-preview:${VERSION}-arm64 74 | docker buildx imagetools create -t \ 75 | dagandersen/argocd-diff-preview:latest \ 76 | dagandersen/argocd-diff-preview:${VERSION}-amd64 \ 77 | dagandersen/argocd-diff-preview:${VERSION}-arm64 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | secrets/ 3 | output/ 4 | .vscode/ 5 | /values.yaml 6 | venv/ 7 | base-branch/ 8 | target-branch/ 9 | temp/ 10 | bin/ 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ArgoCD Diff Preview 2 | 3 | Thank you for your interest in contributing to ArgoCD Diff Preview! This document provides guidelines and instructions for contributing to the project. 4 | 5 | ## Development Environment Setup 6 | 7 | ### Prerequisites 8 | 9 | To develop for ArgoCD Diff Preview, you'll need the following tools: 10 | 11 | 1. **Go** (version 1.21 or later) - The main programming language used in the project 12 | 2. **Docker** - For building containers and running integration tests 13 | 3. **Git** - For version control 14 | 4. **Make** - For running the project's build scripts 15 | 16 | Additionally, these tools are used by the tool but don't need to be installed directly if you are running the tool with Docker. If you are running the tool with Go, you will need to install them. 17 | 18 | - [kind](https://kind.sigs.k8s.io/) - For creating a local Kubernetes cluster 19 | - [kubectl](https://kubernetes.io/docs/reference/kubectl/) - For interacting with the Kubernetes cluster 20 | - [Helm](https://helm.sh/) - For installing Argo CD 21 | - [Argo CD CLI](https://argo-cd.readthedocs.io/en/stable/cli_installation/) - For interacting with Argo CD 22 | 23 | ### Setting Up Your Development Environment 24 | 25 | 1. Clone the repository: 26 | ```bash 27 | git clone https://github.com/dag-andersen/argocd-diff-preview.git 28 | cd argocd-diff-preview 29 | ``` 30 | 31 | 2. Install Go dependencies: 32 | ```bash 33 | go mod download 34 | ``` 35 | 36 | 3. (Optional) Setup for documentation development: 37 | ```bash 38 | python3 -m venv venv 39 | source venv/bin/activate 40 | pip3 install mkdocs-material 41 | ``` 42 | 43 | ## Project Structure 44 | 45 | ``` 46 | argocd-diff-preview/ 47 | ├── cmd/ # Main application entry points 48 | ├── pkg/ # Core application logic 49 | ├── tests/ # Integration tests 50 | ├── docs/ # Documentation 51 | ├── argocd-config/ # Argo CD configuraiton that is installed with Argo CD 52 | └── examples/ # Examples used by the integration tests and pull request examples 53 | ``` 54 | 55 | ## Building the Project 56 | 57 | ### Building the Go Binary 58 | 59 | ```bash 60 | make go-build 61 | ``` 62 | 63 | This will create a binary in the `bin/` directory. 64 | 65 | ### Building the Docker Image 66 | 67 | ```bash 68 | make docker-build 69 | ``` 70 | 71 | ## Running the Project Locally 72 | 73 | There are two ways to run the project locally: 74 | 75 | ### Using branches from the ArgoCD Diff Preview repository 76 | 77 | ```bash 78 | make run-with-go target_branch= 79 | ``` 80 | or 81 | ```bash 82 | make run-with-docker target_branch= 83 | ``` 84 | 85 | _example to make sure the tool works run:_ 86 | ```bash 87 | make run-with-go target_branch=helm-example-3 88 | ``` 89 | 90 | ### Using branches from your own fork 91 | 92 | ```bash 93 | make run-with-go target_branch= github_org= 94 | ``` 95 | 96 | ```bash 97 | make run-with-docker target_branch= github_org= 98 | ``` 99 | 100 | ## Testing 101 | 102 | ArgoCD Diff Preview uses integration tests to verify functionality. These tests create ephemeral Kubernetes clusters and test the application against various test scenarios. 103 | 104 | ### Running All Integration Tests 105 | 106 | Using Go: 107 | ```bash 108 | make run-test-all-go 109 | ``` 110 | 111 | Using Docker: 112 | ```bash 113 | make run-test-all-docker 114 | ``` 115 | 116 | ### Running Unit Tests 117 | 118 | The project includes Go unit tests that can be run using standard Go commands. 119 | 120 | #### Running All Unit Tests 121 | 122 | To run all unit tests in the project: 123 | 124 | ```bash 125 | go test ./... 126 | ``` 127 | 128 | Add the `-v` flag to see detailed output: 129 | 130 | ```bash 131 | go test -v ./... 132 | ``` 133 | 134 | Running Tests in a Specific Package 135 | 136 | To run tests in a specific package, for example the `pkg/types` package: 137 | 138 | ```bash 139 | go test ./pkg/types 140 | ``` 141 | 142 | ## Documentation 143 | 144 | The project uses MkDocs for documentation. To serve the documentation locally: 145 | 146 | ```bash 147 | make mkdocs 148 | ``` 149 | 150 | This will open the documentation in your default browser. 151 | 152 | ## License 153 | 154 | By contributing to ArgoCD Diff Preview, you agree that your contributions will be licensed under the project's license (refer to the LICENSE file in the repository). 155 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1-bookworm AS build 3 | 4 | # Build arguments for version information 5 | ARG VERSION=dev 6 | ARG COMMIT=unknown 7 | ARG BUILD_DATE=unknown 8 | 9 | # https://docs.docker.com/reference/dockerfile/#automatic-platform-args-in-the-global-scope 10 | ARG TARGETARCH 11 | 12 | # create a new empty shell project 13 | WORKDIR /argocd-diff-preview 14 | 15 | # Copy go mod and sum files 16 | COPY go.mod go.sum ./ 17 | 18 | # Download dependencies 19 | RUN go mod download 20 | 21 | # Copy source code - only what's needed 22 | COPY cmd/ ./cmd/ 23 | COPY pkg/ ./pkg/ 24 | 25 | # Build the application with version information 26 | RUN CGO_ENABLED=0 GOOS=linux go build \ 27 | -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" \ 28 | -trimpath \ 29 | -o argocd-diff-preview ./cmd 30 | 31 | # install kind 32 | RUN apt-get update && apt-get install -y curl 33 | RUN curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.28.0/kind-linux-${TARGETARCH} && \ 34 | chmod +x ./kind 35 | 36 | # Install Argo CD 37 | RUN curl -sSL -o argocd-linux-${TARGETARCH} https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-${TARGETARCH} && \ 38 | install -m 555 argocd-linux-${TARGETARCH} /usr/local/bin/argocd && \ 39 | rm argocd-linux-${TARGETARCH} 40 | 41 | FROM gcr.io/distroless/static-debian12 AS final 42 | 43 | # Copy necessary binaries from the build stage 44 | COPY --from=build /argocd-diff-preview/kind /usr/local/bin/kind 45 | COPY --from=build /usr/local/bin/argocd /usr/local/bin/argocd 46 | COPY --from=build /argocd-diff-preview/argocd-diff-preview . 47 | 48 | # Copy docker from the docker image 49 | COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ 50 | 51 | # copy argocd helm chart values 52 | COPY ./argocd-config ./argocd-config 53 | 54 | # set the startup command to run your binary 55 | ENTRYPOINT ["./argocd-diff-preview"] 56 | -------------------------------------------------------------------------------- /argocd-config/values-override.yaml: -------------------------------------------------------------------------------- 1 | # Overrides done by argocd-diff-preview 2 | # These will happen after the values.yaml file is loaded 3 | notifications: 4 | enabled: false 5 | dex: 6 | enabled: false 7 | configs: 8 | params: 9 | reposerver.git.request.timeout: "150s" 10 | # Readiness probe settings have been adjusted to start checks immediately (initialDelaySeconds: 0) 11 | # The failureThreshold is increased to ensure the total time before a pod is marked unhealthy remains similar to the default 12 | # This default duration is calculated using the original values from values.yaml for (initialDelaySeconds + failureThreshold * periodSeconds) 13 | controller: 14 | readinessProbe: 15 | initialDelaySeconds: 0 16 | periodSeconds: 1 17 | successThreshold: 1 18 | timeoutSeconds: 1 19 | failureThreshold: 40 20 | server: 21 | readinessProbe: 22 | initialDelaySeconds: 0 23 | periodSeconds: 1 24 | successThreshold: 1 25 | timeoutSeconds: 1 26 | failureThreshold: 40 27 | repoServer: 28 | readinessProbe: 29 | initialDelaySeconds: 0 30 | periodSeconds: 1 31 | successThreshold: 1 32 | timeoutSeconds: 1 33 | failureThreshold: 40 34 | applicationSet: 35 | readinessProbe: 36 | initialDelaySeconds: 0 37 | periodSeconds: 1 38 | successThreshold: 1 39 | timeoutSeconds: 1 40 | failureThreshold: 40 41 | -------------------------------------------------------------------------------- /argocd-config/values.yaml: -------------------------------------------------------------------------------- 1 | # Override this file with values 2 | 3 | # example 4 | # configs: 5 | # cm: 6 | # kustomize.buildOptions: --load-restrictor LoadRestrictionsNone --enable-helm -------------------------------------------------------------------------------- /docs/assets/article-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/article-banner.png -------------------------------------------------------------------------------- /docs/assets/example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/example-1.png -------------------------------------------------------------------------------- /docs/assets/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/example-2.png -------------------------------------------------------------------------------- /docs/assets/example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/example-3.png -------------------------------------------------------------------------------- /docs/assets/flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/flow.png -------------------------------------------------------------------------------- /docs/assets/flow_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/flow_dark.png -------------------------------------------------------------------------------- /docs/assets/linkedin-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/linkedin-banner.png -------------------------------------------------------------------------------- /docs/assets/logo-head.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/logo-head.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/speed_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dag-andersen/argocd-diff-preview/5bd835e52265f18d4260bed89c413a21a86fe4b0/docs/assets/speed_graph.png -------------------------------------------------------------------------------- /docs/assets/versions.css: -------------------------------------------------------------------------------- 1 | .md-header__title { 2 | display: flex; 3 | } 4 | 5 | .dropdown-caret { 6 | display: inline-block !important; 7 | position: absolute; 8 | right: 4px; 9 | } 10 | 11 | .fa .fa-caret-down { 12 | display: none !important; 13 | } 14 | 15 | .rst-other-versions { 16 | text-align: right; 17 | } 18 | 19 | .rst-other-versions>dl, 20 | .rst-other-versions dt, 21 | .rst-other-versions small { 22 | display: none; 23 | } 24 | 25 | .rst-other-versions>dl:first-child { 26 | display: flex !important; 27 | flex-direction: column; 28 | line-height: 0px !important; 29 | } 30 | 31 | .rst-versions.shift-up .rst-other-versions { 32 | display: flex !important; 33 | } 34 | 35 | .rst-versions .rst-other-versions { 36 | display: none; 37 | } 38 | 39 | /* Version Warning */ 40 | div[data-md-component=announce] { 41 | background-color: rgb(248, 243, 236); 42 | position: sticky; 43 | top: 0; 44 | z-index: 2; 45 | } 46 | 47 | div[data-md-component=announce]>div#announce-msg { 48 | color: var(--md-code-hl-number-color); 49 | font-size: .8rem; 50 | text-align: center; 51 | margin: 15px; 52 | } 53 | 54 | div[data-md-component=announce]>div#announce-msg>a { 55 | color: var(--md-typeset-a-color); 56 | text-decoration: underline; 57 | } 58 | 59 | /* from https://assets.readthedocs.org/static/css/badge_only.css, 60 | most styles have to be overridden here */ 61 | .rst-versions { 62 | position: relative !important; 63 | bottom: 0; 64 | left: 0; 65 | width: 100px !important; 66 | background: hsla(173, 100%, 24%, 1) !important; 67 | font-family: inherit !important; 68 | z-index: 0 !important; 69 | } 70 | 71 | .rst-versions a { 72 | color: #2980B9; 73 | text-decoration: none 74 | } 75 | 76 | .rst-versions .rst-badge-small { 77 | display: none 78 | } 79 | 80 | .rst-versions .rst-current-version { 81 | padding: 12px; 82 | background: hsla(173, 100%, 24%, 1) !important; 83 | display: block; 84 | text-align: right; 85 | font-size: 90%; 86 | cursor: pointer; 87 | color: white !important; 88 | *zoom: 1 89 | } 90 | 91 | .rst-versions .rst-current-version:before, 92 | .rst-versions .rst-current-version:after { 93 | display: table; 94 | content: "" 95 | } 96 | 97 | .rst-versions .rst-current-version:after { 98 | clear: both 99 | } 100 | 101 | .rst-versions .rst-current-version .fa { 102 | color: #fcfcfc 103 | } 104 | 105 | .rst-versions .rst-current-version .fa-caret-down { 106 | display: none; 107 | } 108 | 109 | .rst-versions.shift-up .rst-other-versions { 110 | display: block 111 | } 112 | 113 | .rst-versions .rst-other-versions { 114 | font-size: 90%; 115 | padding: 12px; 116 | color: gray; 117 | display: none 118 | } 119 | 120 | .rst-versions .rst-other-versions hr { 121 | display: none !important; 122 | height: 0px !important; 123 | border: 0px; 124 | margin: 0px !important; 125 | padding: 0px; 126 | border-top: none !important; 127 | } 128 | 129 | .rst-versions .rst-other-versions dd { 130 | display: inline-block; 131 | margin: 0 132 | } 133 | 134 | .rst-versions .rst-other-versions dd a { 135 | display: inline-block; 136 | padding: 1em 0em !important; 137 | color: #fcfcfc; 138 | font-size: .6rem !important; 139 | white-space: nowrap; 140 | text-overflow: ellipsis; 141 | overflow: hidden; 142 | width: 80px; 143 | } 144 | 145 | .rst-versions .rst-other-versions dd a:hover { 146 | font-size: .7rem !important; 147 | font-weight: bold; 148 | } 149 | 150 | .rst-versions.rst-badge { 151 | display: block !important; 152 | width: 100px !important; 153 | bottom: 0px !important; 154 | right: 0px !important; 155 | left: auto; 156 | border: none; 157 | text-align: center !important; 158 | line-height: 0; 159 | } 160 | 161 | .rst-versions.rst-badge .icon-book { 162 | display: none; 163 | } 164 | 165 | .rst-versions.rst-badge .fa-book { 166 | display: none !important; 167 | } 168 | 169 | .rst-versions.rst-badge.shift-up .rst-current-version { 170 | text-align: left !important; 171 | } 172 | 173 | .rst-versions.rst-badge.shift-up .rst-current-version .fa-book { 174 | display: none !important; 175 | } 176 | 177 | .rst-versions.rst-badge.shift-up .rst-current-version .icon-book { 178 | display: none !important; 179 | } 180 | 181 | .rst-versions.rst-badge .rst-current-version { 182 | width: 70px !important; 183 | height: 2.4rem !important; 184 | line-height: 2.4rem !important; 185 | padding: 0px 5px !important; 186 | display: inline-block !important; 187 | font-size: .6rem !important; 188 | overflow: hidden !important; 189 | text-overflow: ellipsis !important; 190 | white-space: nowrap !important; 191 | text-align: left !important; 192 | } 193 | 194 | @media screen and (max-width: 768px) { 195 | .rst-versions { 196 | width: 85%; 197 | display: none 198 | } 199 | 200 | .rst-versions.shift { 201 | display: block 202 | } 203 | } -------------------------------------------------------------------------------- /docs/custom-argo-cd-installation.md: -------------------------------------------------------------------------------- 1 | # Custom Argo CD Installation 2 | 3 | Argo CD is installed using a [Helm Chart](https://artifacthub.io/packages/helm/argo/argo-cd). You can specify the Chart version with the `--argocd-chart-version` option. It defaults to the latest version. 4 | 5 | You can modify the Argo CD Helm Chart installation by providing the tool with a `values.yaml` file and mounting it in the `argocd-config` folder within the container. Check out all the available values in the [Argo CD Helm Chart](https://artifacthub.io/packages/helm/argo/argo-cd). 6 | 7 | *Example:* 8 | 9 | Here we set `configs.cm."kustomize.buildOptions"` in the Chart. 10 | 11 | ```yaml title=".github/workflows/generate-diff.yml" linenums="1" 12 | jobs: 13 | build: 14 | ... 15 | steps: 16 | ... 17 | - name: Set Argo CD Custom Values 18 | run: | 19 | cat > values.yaml << "EOF" 20 | # set whatever helm values you want 21 | configs: 22 | cm: 23 | kustomize.buildOptions: --load-restrictor LoadRestrictionsNone --enable-helm 24 | EOF 25 | 26 | - name: Generate Diff 27 | run: | 28 | docker run \ 29 | --network=host \ 30 | -v /var/run/docker.sock:/var/run/docker.sock \ 31 | -v $(pwd)/main:/base-branch \ 32 | -v $(pwd)/pull-request:/target-branch \ 33 | -v $(pwd)/values.yaml:/argocd-config/values.yaml \ ⬅️ Mount values.yaml 34 | ... 35 | ``` 36 | 37 | --- 38 | 39 | # Argo CD Config Management Plugins (CMP) 40 | 41 | You can install any [Argo CD Config Management Plugin](https://argo-cd.readthedocs.io/en/stable/operator-manual/config-management-plugins/) that is supported through the [Argo CD Helm Chart](https://artifacthub.io/packages/helm/argo/argo-cd). 42 | 43 | *Example:* 44 | 45 | This example installs the [ArgoCD Lovely plugin](https://github.com/crumbhole/argocd-lovely-plugin) using the `values.yaml` file. 46 | 47 | ```yaml title=".github/workflows/generate-diff.yml" linenums="1" 48 | jobs: 49 | build: 50 | ... 51 | steps: 52 | ... 53 | - name: Set Argo CD Custom Values 54 | run: | 55 | cat > values.yaml << "EOF" 56 | repoServer: 57 | extraContainers: 58 | # ArgoCD Lovely plugin - https://github.com/crumbhole/argocd-lovely-plugin 59 | - name: lovely-plugin 60 | image: ghcr.io/crumbhole/lovely:1.1.1 61 | securityContext: 62 | runAsNonRoot: true 63 | runAsUser: 999 64 | volumeMounts: 65 | # Import the repo-server's plugin binary 66 | - mountPath: /var/run/argocd 67 | name: var-files 68 | - mountPath: /home/argocd/cmp-server/plugins 69 | name: plugins 70 | - mountPath: /tmp 71 | name: lovely-tmp 72 | volumes: 73 | - emptyDir: {} 74 | name: lovely-tmp 75 | EOF 76 | 77 | - name: Generate Diff 78 | run: | 79 | docker run \ 80 | --network=host \ 81 | -v /var/run/docker.sock:/var/run/docker.sock \ 82 | -v $(pwd)/main:/base-branch \ 83 | -v $(pwd)/pull-request:/target-branch \ 84 | -v $(pwd)/values.yaml:/argocd-config/values.yaml \ ⬅️ Mount values.yaml 85 | ... 86 | ``` 87 | 88 | !!! important "Questions, issues, or suggestions" 89 | If you experience issues or have any questions, please open an issue in the repository! 🚀 -------------------------------------------------------------------------------- /docs/demo.md: -------------------------------------------------------------------------------- 1 | # Try demo locally with 3 simple commands! 2 | 3 | First, make sure Docker is running. Run `docker ps` to check if it's running. 4 | 5 | Second, run the following 3 commands: 6 | 7 | ```bash 8 | git clone https://github.com/dag-andersen/argocd-diff-preview base-branch --depth 1 -q 9 | 10 | git clone https://github.com/dag-andersen/argocd-diff-preview target-branch --depth 1 -q -b helm-example-3 11 | 12 | docker run \ 13 | --network host \ 14 | -v /var/run/docker.sock:/var/run/docker.sock \ 15 | -v $(pwd)/output:/output \ 16 | -v $(pwd)/base-branch:/base-branch \ 17 | -v $(pwd)/target-branch:/target-branch \ 18 | -e TARGET_BRANCH=helm-example-3 \ 19 | -e REPO=dag-andersen/argocd-diff-preview \ 20 | dagandersen/argocd-diff-preview:v0.1.9 21 | ``` 22 | 23 | and the output would be something like this: 24 | 25 | ``` 26 | ✨ Running with: 27 | ✨ - local-cluster-tool: Kind 28 | ✨ - base-branch: main 29 | ✨ - target-branch: helm-example-3 30 | ✨ - secrets-folder: ./secrets 31 | ✨ - output-folder: ./output 32 | ✨ - repo: dag-andersen/argocd-diff-preview 33 | ✨ - timeout: 180 seconds 34 | 🚀 Creating cluster... 35 | 🚀 Cluster created successfully 36 | 🦑 Installing Argo CD Helm Chart version: 'latest' 37 | 🦑 Installing Argo CD Helm Chart 38 | 🦑 Waiting for Argo CD to start... 39 | 🦑 Argo CD is now available 40 | 🦑 Logging in to Argo CD through CLI... 41 | 🦑 Argo CD installed successfully 42 | 🤷 No secrets found in ./secrets 43 | 🤖 Fetching all files in dir: base-branch 44 | 🤖 Patching applications for branch: main 45 | 🤖 Patching 4 Argo CD Application[Sets] for branch: main 46 | 🤖 Fetching all files in dir: target-branch 47 | 🤖 Patching applications for branch: helm-example-3 48 | 🤖 Patching 4 Argo CD Application[Sets] for branch: helm-example-3 49 | 🌚 Getting resources from base 50 | ⏳ Waiting for 4 out of 4 applications to become 'OutOfSync'. Retrying in 5 seconds. Timeout in 180 seconds... 51 | 🌚 Got all resources from 4 applications for base 52 | 🧼 Removing applications 53 | 🧼 Removed applications successfully 54 | 🌚 Getting resources from target 55 | ⏳ Waiting for 3 out of 4 applications to become 'OutOfSync'. Retrying in 5 seconds. Timeout in 180 seconds... 56 | 🌚 Got all resources from 4 applications for target 57 | 💥 Deleting cluster... 58 | 🔮 Generating diff between main and helm-example-3 59 | 🙏 Please check the ./output/diff.md file for differences 60 | 🎉 Done in 99 seconds 61 | ``` 62 | 63 | Finally, you can view the diff by running `cat ./output/diff.md`. The diff should look something like [this](https://github.com/dag-andersen/argocd-diff-preview/pull/16) 64 | 65 | !!! important "Questions, issues, or suggestions" 66 | If you experience issues or have any questions, please open an issue in the repository! 🚀 -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions (FAQ) 2 | 3 | --- 4 | 5 | ### Does the tool work with `ApplicationSets`? 6 | 7 | **Short answer:** : Yes. 8 | 9 | **Longer Answer:** Yes, but _how well it works_ depends on the complexity of your generators. 10 | 11 | - **List generator**: 12 | Yes, no issues reported 13 | 14 | - **Cluster generator**: 15 | Yes, but you have to add the ClusterSecrets to the `secrets` folder. Similar to how the secrets are provided [here](https://dag-andersen.github.io/argocd-diff-preview/github-actions-workflow) 16 | 17 | - **Git generator**: 18 | Yes, no issues reported 19 | 20 | - **Matrix generator**: 21 | Yes, no issues reported 22 | 23 | - **Merge generator**: 24 | Yes, no issues reported 25 | 26 | - **Plugin generator**: 27 | Should work, but not tested. Read more on how to install a plugin [here](https://dag-andersen.github.io/argocd-diff-preview/custom-argo-cd-installation). Related Issue: [#40](https://github.com/dag-andersen/argocd-diff-preview/issues/40) 28 | 29 | - **Pull Request generator**: 30 | Should work, but not tested. 31 | 32 | - **SCM Provider generator**: 33 | Not tested 34 | 35 | - **Cluster Decision Resource generator**: 36 | Not tested 37 | 38 | --- 39 | 40 | 41 | ### Does it work with Config Management Plugins (CMP) 42 | 43 | Yes. More info [docs](https://dag-andersen.github.io/argocd-diff-preview/custom-argo-cd-installation) 44 | 45 | --- 46 | 47 | ### Does it work with Git providers other than GitHub and GitLab? 48 | 49 | **Short answer:** Yes. 50 | 51 | **Longer answer:** In theory, yes, but not all providers have been tested. If you are using a different Git provider and encounter issues, please open an issue in the repository. Additionally, if you have successfully used the tool with a different provider, consider contributing to the documentation so others can benefit from a working example ❤️ 52 | 53 | Relevant issue: [#94](https://github.com/dag-andersen/argocd-diff-preview/issues/94) 54 | 55 | --- 56 | 57 | ### Does it work with the Apps of Apps Pattern? 58 | 59 | **Short answer:** Yes, but it depends on your setup. 60 | 61 | **Longer answer:** The Apps of Apps Pattern can be configured in many ways, making it challenging to handle all cases. This tool identifies and renders all resources with `kind: Application` or `kind: ApplicationSet`. If your Applications or ApplicationSets are written as plain manifests in your repository, the tool will work seamlessly. However, if you have an Application that deploys a Helm chart, which then deploys the rest of your Applications, you will need to render the Helm chart before running the tool. 62 | 63 | Relevant issue: [#75](https://github.com/dag-andersen/argocd-diff-preview/issues/75) 64 | 65 | #### Why can't the tool automatically render my Helm Charts or Kustomize templates? 66 | 67 | Helm and Kustomize configurations are inherently complex: 68 | 69 | - **Helm:** Any YAML file can be used as a values file for Helm charts, making it impossible for the tool to automatically determine which YAML files should be used as values files and which Helm charts they belong to. 70 | - **Kustomize:** Overlays in Kustomize can be chained in various ways. The tool cannot reliably figure out which overlays to use or skip. 71 | 72 | Because of this, users must render their Helm charts and Kustomize templates before running the tool. 73 | 74 | The tool is rather conservative in making assumptions about how Applications are rendered, with the goal of avoiding false positives. 75 | 76 | More info: [docs](https://dag-andersen.github.io/argocd-diff-preview/generated-applications/) 77 | 78 | --- 79 | 80 | ### Does it work with a distributed Argo CD repository setup? 81 | 82 | **Short answer:** Maybe... It depends on what you are trying to achieve. 83 | 84 | **Longer answer:** The tool is not specifically designed to work with a distributed Argo CD repository setup, where applications and their manifests are spread across multiple repositories. 85 | 86 | --- 87 | 88 | ### How do I speed up the tool? 89 | 90 | **Short answer:** Limit the number of applications rendered. 91 | 92 | **Longer answer:** Rendering the manifests for all applications in the repository on each pull request can be time-consuming. Limiting the number of applications rendered can significantly speed up the process. By default, `argocd-diff-preview` renders all applications in the repository. 93 | 94 | Check out the [full documentation](https://dag-andersen.github.io/argocd-diff-preview/application-selection/) to learn how to limit the number of applications rendered. 95 | -------------------------------------------------------------------------------- /docs/generated-applications.md: -------------------------------------------------------------------------------- 1 | # Helm/Kustomize generated ArgoCD applications 2 | 3 | `argocd-diff-preview` will only look for YAML files in the repository with `kind: Application` or `kind: ApplicationSet`. If your applications are generated from a Helm chart or Kustomize template, you will have to add a step in the pipeline that renders the chart/template. 4 | 5 | *Helm and Kustomize examples:* 6 | 7 | ```yaml title=".github/workflows/generate-diff.yml" linenums="1" 8 | jobs: 9 | build: 10 | ... 11 | steps: 12 | ... 13 | - uses: actions/checkout@v4 14 | with: 15 | path: pull-request 16 | 17 | - name: Generate with helm chart 18 | run: helm template pull-request/some/path/my-chart > pull-request/rendered-apps.yaml 19 | 20 | - name: Generate with kustomize 21 | run: kustomize build pull-request/some/path/my-kustomize > pull-request/rendered-apps.yaml 22 | 23 | - name: Generate Diff 24 | run: | 25 | docker run \ 26 | --network=host \ 27 | -v /var/run/docker.sock:/var/run/docker.sock \ 28 | -v $(pwd)/main:/base-branch \ 29 | ... 30 | ``` 31 | This will place the rendered manifests inside the `pull-request` folder, and the tool will pick them up. 32 | -------------------------------------------------------------------------------- /docs/gitlab-cicd.md: -------------------------------------------------------------------------------- 1 | # GitLab CI/CD Workflow 2 | 3 | ## Public repositories 4 | 5 | If your repository is public and only uses public Helm charts, you can use the following GitLab CI/CD pipeline to generate a diff between the main branch and the merge request branch. The diff will then be posted as a comment on the merge request. 6 | 7 | ```yaml 8 | default: 9 | tags: 10 | - gitlab-org-docker 11 | 12 | stages: 13 | - diff 14 | 15 | diff: 16 | stage: diff 17 | image: docker:24.0.5 18 | services: 19 | - name: docker:24.0.5-dind 20 | variables: 21 | GITLAB_TOKEN: $GITLAB_PAT 22 | before_script: 23 | - apk add -q curl jq git 24 | script: 25 | - | 26 | echo "******** Running analysis ********" 27 | git clone ${CI_REPOSITORY_URL} base-branch --depth 1 -q 28 | git clone ${CI_REPOSITORY_URL} target-branch --depth 1 -q -b ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} 29 | docker run \ 30 | --network host \ 31 | -v /var/run/docker.sock:/var/run/docker.sock \ 32 | -v $(pwd)/output:/output \ 33 | -v $(pwd)/base-branch:/base-branch \ 34 | -v $(pwd)/target-branch:/target-branch \ 35 | -e TARGET_BRANCH=${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} \ 36 | -e REPO=${CI_MERGE_REQUEST_PROJECT_PATH} \ 37 | dagandersen/argocd-diff-preview:v0.1.9 38 | - | 39 | DIFF_BODY=$(jq -Rs '.' < $(pwd)/output/diff.md) 40 | NOTE_ID=$(curl --silent --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ 41 | "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" | \ 42 | jq '.[] | select(.body | test("Argo CD Diff Preview")) | .id') 43 | 44 | if [[ -n "$NOTE_ID" ]]; then 45 | echo "Deleting existing comment (ID: $NOTE_ID)..." 46 | 47 | curl --silent --request DELETE --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ 48 | --url "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes/${NOTE_ID}" 49 | fi 50 | 51 | echo "Adding new comment..." 52 | curl --silent --request POST --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ 53 | --header "Content-Type: application/json" \ 54 | --url "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" \ 55 | --data "{\"body\": $DIFF_BODY}" > /dev/null 56 | 57 | echo "Comment added!" 58 | rules: 59 | - if: $CI_PIPELINE_SOURCE == "merge_request_event" 60 | ``` 61 | 62 | ## Private repositories and Helm Charts 63 | 64 | In the simple code example above, we do not provide the cluster with any credentials, which only works if the image/Helm Chart registry and the Git repository are public. Since your repository might not be public, you need to provide the tool with the necessary read-access credentials for the repository. This can be done by placing the Argo CD repo secrets in a folder mounted at /secrets. When the tool starts, it will simply run `kubectl apply -f /secrets` to apply every resource to the cluster before starting the rendering process. 65 | 66 | ```yaml 67 | ... 68 | before_script: 69 | - apk add -q curl jq git 70 | - | 71 | mkdir secrets 72 | cat > secrets/secret.yaml << EOF 73 | apiVersion: v1 74 | kind: Secret 75 | metadata: 76 | name: private-repo 77 | namespace: argocd 78 | labels: 79 | argocd.argoproj.io/secret-type: repo-creds 80 | stringData: 81 | url: https://gitlab.com/${CI_PROJECT_PATH} 82 | password: ${GITLAB_TOKEN} ⬅️ Short-lived GitLab Token 83 | username: token 84 | EOF 85 | 86 | script: 87 | - | 88 | echo "******** Running analysis ********" 89 | git clone ${CI_REPOSITORY_URL} base-branch --depth 1 -q 90 | git clone ${CI_REPOSITORY_URL} target-branch --depth 1 -q -b ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} 91 | docker run \ 92 | --network host \ 93 | -v /var/run/docker.sock:/var/run/docker.sock \ 94 | -v $(pwd)/output:/output \ 95 | -v $(pwd)/base-branch:/base-branch \ 96 | -v $(pwd)/target-branch:/target-branch \ 97 | -v $(pwd)/secrets:/secrets \ ⬅️ Mount the secrets folder 98 | -e TARGET_BRANCH=${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} \ 99 | -e REPO=${CI_MERGE_REQUEST_PROJECT_PATH} \ 100 | dagandersen/argocd-diff-preview:v0.1.9 101 | 102 | ``` 103 | 104 | For more info, see the [Argo CD docs](https://argo-cd.readthedocs.io/en/stable/operator-manual/argocd-repo-creds-yaml/) 105 | -------------------------------------------------------------------------------- /docs/ignoring-lines.md: -------------------------------------------------------------------------------- 1 | # Ignore specific lines in the diff preview 2 | 3 | Since this tool only highlights diffs between branches, it is important to stay up to date with your main branch. If your main branch is updated often with new tags for you container images, it can be hard to keep up with the newest changes. 4 | 5 | You might see a lot of previews including simple changes like `image: my-image:v1.0.0` to `image: my-image:v1.0.1`. 6 | 7 | *Example:* 8 | 9 | ```diff 10 | diff --git base/deployment target/deployment 11 | @@ -3,38 +3,38 @@ template: 12 | spec: 13 | containers: 14 | - name: my-app 15 | - image: dag-andersen/my-app:v1.0.1 16 | + image: dag-andersen/my-app:v1.0.2 17 | ports: 18 | - containerPort: 80 19 | ``` 20 | 21 | To avoid this, you can ignore lines in the diff by using the `--diff-ignore` option. 22 | 23 | ```bash 24 | argocd-diff-preview --diff-ignore="v[1,9]+.[1,9]+.[1,9]+" 25 | ``` 26 | 27 | This will ignore changes like in the example above. 28 | 29 | `argocd-diff-preview` uses `git diff` for generating the diff. For more information on how the lines are ignored, read their docs: [git-diff](https://git-scm.com/docs/git-diff). -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | ![Argo CD Diff Preview](./assets/logo.png) 4 | 5 | Argo CD Diff Preview is a tool that renders the diff between two branches in a Git repository. It is designed to render manifests generated by Argo CD, providing a clear and concise view of the changes between two branches. It operates similarly to Atlantis for Terraform, creating a plan that outlines the proposed changes. 6 | 7 | ### 3 Example Pull Requests: 8 | - [Helm Example | Internal Chart](https://github.com/dag-andersen/argocd-diff-preview/pull/16) 9 | - [Helm example | External Chart: Nginx](https://github.com/dag-andersen/argocd-diff-preview/pull/15) 10 | - [Kustomize Example](https://github.com/dag-andersen/argocd-diff-preview/pull/12) 11 | 12 | --- 13 | 14 | ![](./assets/example-1.png) 15 | 16 | ## Overview 17 | 18 | ![](./assets/flow_dark.png) 19 | 20 | The safest way to make changes to you Helm Charts and Kustomize Overlays in your GitOps repository is to let Argo CD render them for you. This can be done by spinning up an ephemeral cluster in your automated pipelines. Since the diff is rendered by Argo CD itself, it is as accurate as possible. 21 | 22 | The implementation is actually quite simple. It just follows the steps below: 23 | 24 | ## 10 Steps 25 | 1. Start a local cluster 26 | 2. Install Argo CD 27 | 3. Add the required credentials (git credentials, image pull secrets, etc.) 28 | 4. Fetch all Argo CD applications from your PR branch 29 | - Point their `targetRevision` to the Pull Request branch 30 | - Remove the `syncPolicy` from the applications (to avoid the applications syncing locally) 31 | 5. Apply the modified applications to the cluster 32 | 6. Let Argo CD do its magic 33 | 7. Extract the rendered manifests from the Argo CD server 34 | 8. Repeat steps 4–7 for the base branch (main branch) 35 | 9. Create a diff between the manifests rendered from each branch 36 | 10. Display the diff in the PR 37 | 38 | ## Features 39 | 40 | - Renders manifests generated by Argo CD 41 | - Does not require access to your real cluster or Argo CD instance. The tool runs in complete isolation. 42 | - Can be run locally before you open the pull request 43 | - Works with private repositories and Helm charts 44 | - Provides a clear and concise view of the changes 45 | - Render resources from external sources (e.g., Helm charts). For example, when you update the chart version of Nginx, you can get a render of the new output. For example, this is useful to spot changes in default values. [PR example](https://github.com/dag-andersen/argocd-diff-preview/pull/15). 46 | 47 | ## Why do we need this? 48 | 49 | In the Kubernetes world, we often use templating tools like Kustomize and Helm to generate our Kubernetes manifests. These tools make maintaining and streamlining configuration easier across applications and environments. However, they also make it harder to visualize the application's actual configuration in the cluster. 50 | 51 | Mentally parsing Helm templates and Kustomize patches is hard without rendering the actual output. Thus, making mistakes while modifying an application's configuration is relatively easy. 52 | 53 | In the field of GitOps and infrastructure as code, all configurations are checked into Git and modified through PRs. The code changes in the PR are reviewed by a human, who needs to understand the changes made to the configuration. This is hard when the configuration is generated through templating tools like Kustomize and Helm. 54 | 55 | ## ArgoCon 2024 Talk 56 | 57 | `argocd-diff-preview` was presented at ArgoCon 2024 in Utah, US. The talk covered current tools and methods for visualizing code changes in GitOps workflows and introduced this new approach, which uses ephemeral clusters to render accurate diffs directly on your pull requests. 58 | 59 | - Talk description: [GitOps Safety: Rendering Accurate ArgoCD Diffs Directly on Pull Requests]( 60 | https://colocatedeventsna2024.sched.com/event/1izsL/gitops-safety-rendering-accurate-argocd-diffs-directly-on-pull-requests-dag-bjerre-andersen-visma-regina-voloshin-octopus-deploy) 61 | - Talk recording: [YouTube](https://youtu.be/3aeP__qPSms) 62 | 63 | ## Contributing 64 | 65 | We welcome contributions to ArgoCD Diff Preview! Whether you want to fix bugs, add new features, or improve documentation, your help is appreciated. 66 | 67 | Please check out our [contribution guidelines](https://github.com/dag-andersen/argocd-diff-preview/blob/main/CONTRIBUTING.md) for details on how to set up your development environment, run tests, and submit changes. -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Running it locally 2 | 3 | === "Docker" 4 | 5 | ## Pre-requisites 6 | 7 | - Install: [Docker](https://docs.docker.com/get-docker/) 8 | 9 | ## Usage 10 | 11 | You need to pull down the two branches you want to compare. The first branch will be cloned into the `base-branch` folder, and the other branch will be cloned into the `target-branch` folder. 12 | 13 | ```bash 14 | git clone https://github.com// base-branch --depth 1 -q -b 15 | 16 | git clone https://github.com// target-branch --depth 1 -q -b 17 | ``` 18 | 19 | Then you can run the tool using the following command: 20 | 21 | ```bash 22 | docker run \ 23 | --network host \ 24 | -v /var/run/docker.sock:/var/run/docker.sock \ 25 | -v $(pwd)/output:/output \ 26 | -v $(pwd)/base-branch:/base-branch \ 27 | -v $(pwd)/target-branch:/target-branch \ 28 | -e TARGET_BRANCH= \ 29 | -e BASE_BRANCH= \ 30 | -e REPO=/ \ 31 | dagandersen/argocd-diff-preview:v0.1.9 32 | ``` 33 | 34 | If base-branch(`BASE_BRANCH`) is not specified it will default to `main`. 35 | 36 | === "Binary" 37 | 38 | ## Pre-requisites 39 | 40 | Install: 41 | 42 | - [Git](https://git-scm.com/downloads) 43 | - [Docker](https://docs.docker.com/get-docker/) 44 | - [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) 45 | - [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) OR [minikube](https://minikube.sigs.k8s.io/docs/start/) 46 | - [Argo CD CLI](https://argo-cd.readthedocs.io/en/stable/cli_installation/) 47 | 48 | ## Find the correct binary for your operating system 49 | 50 | Check the [releases](https://github.com/dag-andersen/argocd-diff-preview/releases) and find the correct binary for your operating system. 51 | 52 | *Example for downloading and running on macOS:* 53 | 54 | ```bash 55 | curl -LJO https://github.com/dag-andersen/argocd-diff-preview/releases/download/v0.1.9/argocd-diff-preview-Darwin-x86_64.tar.gz 56 | tar -xvf argocd-diff-preview-Darwin-x86_64.tar.gz 57 | sudo mv argocd-diff-preview /usr/local/bin 58 | argocd-diff-preview --help 59 | ``` 60 | 61 | ## Usage 62 | 63 | You need to pull down the two branches you want to compare. The first branch will be cloned into the `base-branch` folder, and the other branch will be cloned into the `target-branch` folder. 64 | 65 | ```bash 66 | git clone https://github.com// base-branch --depth 1 -q -b 67 | 68 | git clone https://github.com// target-branch --depth 1 -q -b 69 | ``` 70 | 71 | ## Run the binary 72 | ```bash 73 | 74 | argocd-diff-preview \ 75 | --repo / \ 76 | --base-branch \ 77 | --target-branch 78 | ``` 79 | 80 | If base-branch is not specified it will default to `main`. 81 | 82 | 125 | -------------------------------------------------------------------------------- /docs/options.md: -------------------------------------------------------------------------------- 1 | # Options 2 | 3 | This document describes all the available options for ArgoCD Diff Preview. Options can be provided via command-line flags or environment variables. 4 | 5 | ## Usage 6 | 7 | ``` 8 | argocd-diff-preview [FLAGS] [OPTIONS] --repo --target-branch 9 | ``` 10 | 11 | ## Required Options 12 | 13 | | Flag | Environment Variable | Description | 14 | |------|---------------------|-------------| 15 | | `--repo ` | `REPO` | Git Repository in format OWNER/REPO (e.g., `dag-andersen/argocd-diff-preview`) | 16 | | `--target-branch `, `-t` | `TARGET_BRANCH` | Target branch name (the branch you want to compare with the base branch) | 17 | 18 | ## Flags 19 | 20 | | Flag | Environment Variable | Default | Description | 21 | |------|---------------------|---------|-------------| 22 | | `--debug`, `-d` | `DEBUG` | `false` | Activate debug mode | 23 | | `--ignore-invalid-watch-pattern` | `IGNORE_INVALID_WATCH_PATTERN` | `false` | Ignore invalid watch pattern Regex on Applications | 24 | | `--keep-cluster-alive` | `KEEP_CLUSTER_ALIVE` | `false` | Keep cluster alive after the tool finishes | 25 | | `--help`, `-h` | - | - | Prints help information | 26 | | `--version`, `-V` | - | - | Prints version information | 27 | 28 | ## Options 29 | 30 | | Option | Environment Variable | Default | Description | 31 | |--------|---------------------|---------|-------------| 32 | | `--argocd-chart-version ` | `ARGOCD_CHART_VERSION` | `latest` | Argo CD Helm Chart version | 33 | | `--argocd-namespace ` | `ARGOCD_NAMESPACE` | `argocd` | Namespace to use for Argo CD | 34 | | `--base-branch `, `-b` | `BASE_BRANCH` | `main` | Base branch name | 35 | | `--cluster ` | `CLUSTER` | `auto` | Local cluster tool. Options: kind, minikube, auto | 36 | | `--cluster-name ` | `CLUSTER_NAME` | `argocd-diff-preview` | Cluster name (only for kind) | 37 | | `--kind-options ` | `KIND_OPTIONS` | - | Additional options for kind cluster creation | 38 | | `--kind-internal` | `KIND_INTERNAL` | `false` | Use the kind cluster's internal address in the kubeconfig. Allows connecting to it when running the CLI in a container. | 39 | | `--diff-ignore `, `-i` | `DIFF_IGNORE` | - | Ignore lines in diff. Example: `v[1,9]+.[1,9]+.[1,9]+` for ignoring version changes | 40 | | `--file-regex `, `-r` | `FILE_REGEX` | - | Regex to filter files. Example: `/apps_.*\.yaml` | 41 | | `--files-changed ` | `FILES_CHANGED` | - | List of files changed between branches (comma, space or newline separated) | 42 | | `--line-count `, `-c` | `LINE_COUNT` | `10` | Generate diffs with \ lines of context | 43 | | `--log-format ` | `LOG_FORMAT` | `human` | Log format. Options: human, json | 44 | | `--max-diff-length ` | `MAX_DIFF_LENGTH` | `65536` | Max diff message character count. It only limits the generated Markdown file | 45 | | `--output-folder `, `-o` | `OUTPUT_FOLDER` | `./output` | Output folder where the diff will be saved | 46 | | `--redirect-target-revisions ` | `REDIRECT_TARGET_REVISIONS` | - | List of target revisions to redirect | 47 | | `--secrets-folder `, `-s` | `SECRETS_FOLDER` | `./secrets` | Secrets folder where the secrets are read from | 48 | | `--selector `, `-l` | `SELECTOR` | - | Label selector to filter on (e.g., `key1=value1,key2=value2`) | 49 | | `--timeout ` | `TIMEOUT` | `180` | Set timeout in seconds | 50 | | `--title ` | `TITLE` | `Argo CD Diff Preview` | Custom title for the markdown output | 51 | -------------------------------------------------------------------------------- /docs/self-hosted-gh-runner.md: -------------------------------------------------------------------------------- 1 | # Self-hosted Github Actions Runners on kubernetes 2 | 3 | When using Action Runner Controller (ARC) to run self-host your GitHub Actions Runners. You need to ensure that the Service and Pod CIDRs of the kind cluster created by `argocd-diff-preview` don't overlap with your host cluster's CIDRs. 4 | 5 | The default CIDRs are: 6 | 7 | | Service | CIDR | 8 | |---------|-------------| 9 | | Service | 10.96.0.0/16| 10 | | Pod | 10.244.0.0/16| 11 | 12 | To configure kind: 13 | 14 | 1. Create a file in your repo, for instance `hack/kind.yaml`, with the following content: 15 | ```yaml 16 | kind: Cluster 17 | apiVersion: kind.x-k8s.io/v1alpha4 18 | networking: 19 | serviceSubnet: "10.80.0.0/16" 20 | podSubnet: "10.128.0.0/16" 21 | ``` 22 | 2. Add the flag `--kind-options '--config /base-branch/hack/kind.yaml'` to `argocd-diff-preview`. 23 | ```yaml 24 | - name: Generate Diff 25 | run: | 26 | docker run \ 27 | --network=host \ 28 | -v /var/run/docker.sock:/var/run/docker.sock \ 29 | -v $(pwd)/main:/base-branch \ 30 | -v $(pwd)/pull-request:/target-branch \ 31 | -v $(pwd)/output:/output \ 32 | -e TARGET_BRANCH=refs/pull/${{ github.event.number }}/merge \ 33 | -e REPO=${{ github.repository }} \ 34 | dagandersen/argocd-diff-preview:v0.1.9 --kind-options '--config /base-branch/hack/kind.yaml' 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | ## Debug Mode 2 | 3 | If you are having trouble with the tool, you can enable debug mode to get more information about what is going wrong. To enable debug mode run the tool with the `--debug` flag. 4 | 5 | If that doesn't help or you still have questions, please open an issue in the repository! 6 | 7 | -------------------------------------------------------------------------------- /examples/basic-appset/my-app-set.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: ApplicationSet 3 | metadata: 4 | name: my-app-set 5 | namespace: argocd 6 | spec: 7 | goTemplate: true 8 | goTemplateOptions: ["missingkey=error"] 9 | generators: 10 | - list: 11 | elements: 12 | - name: dev 13 | - name: prod 14 | - name: staging 15 | template: 16 | metadata: 17 | name: 'my-app-set-{{.name}}' 18 | spec: 19 | project: default 20 | destination: 21 | name: in-cluster 22 | namespace: default 23 | sources: 24 | - repoURL: https://github.com/dag-andersen/argocd-diff-preview 25 | ref: local-files 26 | - path: examples/helm/charts/myApp 27 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 28 | helm: 29 | valueFiles: 30 | - $local-files/examples/helm/values/my-app-set.yaml -------------------------------------------------------------------------------- /examples/custom-target-revision/app/app.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: custom-target-revision-example 5 | namespace: argocd 6 | spec: 7 | project: default 8 | destination: 9 | server: https://kubernetes.default.svc 10 | namespace: default 11 | sources: 12 | - path: examples/custom-target-revision/resources 13 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 14 | targetRevision: example-redirect-tag -------------------------------------------------------------------------------- /examples/custom-target-revision/resources/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: my-deployment 5 | spec: 6 | replicas: 2 7 | template: 8 | spec: 9 | containers: 10 | - name: my-deployment 11 | image: dag-andersen/myapp:latest 12 | ports: 13 | - containerPort: 80 14 | -------------------------------------------------------------------------------- /examples/destination-server/app.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: destination-server-example 5 | namespace: argocd 6 | spec: 7 | project: default 8 | destination: 9 | server: https://kubernetes.default.svc 10 | namespace: default 11 | sources: 12 | - path: examples/helm/charts/myApp 13 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 14 | -------------------------------------------------------------------------------- /examples/duplicate-names/app/app-set-1.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: ApplicationSet 3 | metadata: 4 | name: duplicate-names-example 5 | namespace: argocd 6 | spec: 7 | goTemplate: true 8 | goTemplateOptions: ["missingkey=error"] 9 | generators: 10 | - list: 11 | elements: 12 | - name: app1 13 | path: examples/duplicate-names/resources 14 | - name: app2 15 | path: examples/duplicate-names/resources 16 | template: 17 | metadata: 18 | name: "{{ .name }}" 19 | finalizers: 20 | - resources-finalizer.argocd.argoproj.io 21 | spec: 22 | project: default 23 | source: 24 | repoURL: https://github.com/dag-andersen/argocd-diff-preview.git 25 | path: "{{ .path }}" 26 | destination: 27 | name: in-cluster 28 | syncPolicy: 29 | syncOptions: 30 | - CreateNamespace=true 31 | -------------------------------------------------------------------------------- /examples/duplicate-names/app/app-set-2.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: ApplicationSet 3 | metadata: 4 | name: duplicate-names-example 5 | namespace: argocd 6 | spec: 7 | goTemplate: true 8 | goTemplateOptions: ["missingkey=error"] 9 | generators: 10 | - list: 11 | elements: 12 | - name: app1 13 | path: examples/duplicate-names/resources 14 | - name: app2 15 | path: examples/duplicate-names/resources 16 | template: 17 | metadata: 18 | name: "{{ .name }}" 19 | finalizers: 20 | - resources-finalizer.argocd.argoproj.io 21 | spec: 22 | project: default 23 | source: 24 | repoURL: https://github.com/dag-andersen/argocd-diff-preview.git 25 | path: "{{ .path }}" 26 | destination: 27 | name: in-cluster 28 | syncPolicy: 29 | syncOptions: 30 | - CreateNamespace=true 31 | -------------------------------------------------------------------------------- /examples/duplicate-names/resources/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: deploy-from-folder-one 5 | spec: 6 | replicas: 2 7 | selector: 8 | matchLabels: 9 | app: myapp 10 | template: 11 | metadata: 12 | labels: 13 | app: myapp 14 | spec: 15 | containers: 16 | - name: myapp 17 | image: dag-andersen/myapp:latest 18 | ports: 19 | - containerPort: 80 20 | -------------------------------------------------------------------------------- /examples/external-chart/nginx.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: nginx-ingress 5 | namespace: argocd 6 | spec: 7 | project: default 8 | destination: 9 | name: in-cluster 10 | namespace: default 11 | sources: 12 | - chart: ingress-nginx 13 | repoURL: "https://kubernetes.github.io/ingress-nginx" 14 | targetRevision: 4.10.0 15 | helm: 16 | valuesObject: 17 | controller: 18 | ingressClass: "test" 19 | -------------------------------------------------------------------------------- /examples/git-generator/app/app-set.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: ApplicationSet 3 | metadata: 4 | name: git-generator-example-appset 5 | namespace: argocd 6 | annotations: 7 | argocd-diff-preview/watch-pattern: "examples/git-generator/resources/.*" 8 | spec: 9 | goTemplate: true 10 | goTemplateOptions: ["missingkey=error"] 11 | generators: 12 | - git: 13 | repoURL: https://github.com/dag-andersen/argocd-diff-preview.git 14 | revision: HEAD 15 | directories: 16 | - path: examples/git-generator/resources/** 17 | values: 18 | name: "{{ index .path.segments 3 }}" 19 | template: 20 | metadata: 21 | name: "{{ .values.name }}" 22 | annotations: 23 | argocd-diff-preview/watch-pattern: '{{ .path.path }}/.*' 24 | finalizers: 25 | - resources-finalizer.argocd.argoproj.io 26 | spec: 27 | project: default 28 | source: 29 | repoURL: https://github.com/dag-andersen/argocd-diff-preview.git 30 | path: "{{ .path.path }}" 31 | destination: 32 | name: in-cluster 33 | syncPolicy: 34 | syncOptions: 35 | - CreateNamespace=true 36 | -------------------------------------------------------------------------------- /examples/git-generator/resources/folder1/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: deploy-from-folder-one 5 | spec: 6 | replicas: 2 7 | selector: 8 | matchLabels: 9 | app: myapp 10 | template: 11 | metadata: 12 | labels: 13 | app: myapp 14 | spec: 15 | containers: 16 | - name: myapp 17 | image: dag-andersen/myapp:latest 18 | ports: 19 | - containerPort: 80 20 | -------------------------------------------------------------------------------- /examples/git-generator/resources/folder2/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: deploy-from-folder-two 5 | spec: 6 | replicas: 2 7 | selector: 8 | matchLabels: 9 | app: myapp 10 | template: 11 | metadata: 12 | labels: 13 | app: myapp 14 | spec: 15 | containers: 16 | - name: myapp 17 | image: dag-andersen/myapp:latest 18 | ports: 19 | - containerPort: 80 20 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: myApp 3 | description: A Helm chart for Kubernetes 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "1.16.0" 25 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "myApp.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "myApp.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "myApp.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "myApp.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "myApp.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "myApp.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "myApp.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "myApp.labels" -}} 37 | helm.sh/chart: {{ include "myApp.chart" . }} 38 | {{ include "myApp.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "myApp.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "myApp.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "myApp.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "myApp.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "myApp.fullname" . }} 5 | labels: 6 | {{- include "myApp.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "myApp.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "myApp.labels" . | nindent 8 }} 22 | {{- with .Values.podLabels }} 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | spec: 26 | {{- with .Values.imagePullSecrets }} 27 | imagePullSecrets: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | serviceAccountName: {{ include "myApp.serviceAccountName" . }} 31 | securityContext: 32 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 33 | containers: 34 | - name: {{ .Chart.Name }} 35 | securityContext: 36 | {{- toYaml .Values.securityContext | nindent 12 }} 37 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 38 | imagePullPolicy: {{ .Values.image.pullPolicy }} 39 | ports: 40 | - name: http 41 | containerPort: {{ .Values.service.port }} 42 | protocol: TCP 43 | livenessProbe: 44 | {{- toYaml .Values.livenessProbe | nindent 12 }} 45 | readinessProbe: 46 | {{- toYaml .Values.readinessProbe | nindent 12 }} 47 | resources: 48 | {{- toYaml .Values.resources | nindent 12 }} 49 | {{- with .Values.volumeMounts }} 50 | volumeMounts: 51 | {{- toYaml . | nindent 12 }} 52 | {{- end }} 53 | {{- with .Values.volumes }} 54 | volumes: 55 | {{- toYaml . | nindent 8 }} 56 | {{- end }} 57 | {{- with .Values.nodeSelector }} 58 | nodeSelector: 59 | {{- toYaml . | nindent 8 }} 60 | {{- end }} 61 | {{- with .Values.affinity }} 62 | affinity: 63 | {{- toYaml . | nindent 8 }} 64 | {{- end }} 65 | {{- with .Values.tolerations }} 66 | tolerations: 67 | {{- toYaml . | nindent 8 }} 68 | {{- end }} 69 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "myApp.fullname" . }} 6 | labels: 7 | {{- include "myApp.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "myApp.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | target: 21 | type: Utilization 22 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 23 | {{- end }} 24 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | target: 29 | type: Utilization 30 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 31 | {{- end }} 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "myApp.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "myApp.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "myApp.fullname" . }} 5 | labels: 6 | {{- include "myApp.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "myApp.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "myApp.serviceAccountName" . }} 6 | labels: 7 | {{- include "myApp.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "myApp.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "myApp.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "myApp.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /examples/helm/charts/myApp/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for myApp. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: nginx 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "" 12 | 13 | imagePullSecrets: [] 14 | nameOverride: "" 15 | fullnameOverride: test-namespace 16 | 17 | serviceAccount: 18 | # Specifies whether a service account should be created 19 | create: true 20 | # Automatically mount a ServiceAccount's API credentials? 21 | automount: true 22 | # Annotations to add to the service account 23 | annotations: {} 24 | # The name of the service account to use. 25 | # If not set and create is true, a name is generated using the fullname template 26 | name: "" 27 | 28 | podAnnotations: {} 29 | podLabels: {} 30 | 31 | podSecurityContext: {} 32 | # fsGroup: 2000 33 | 34 | securityContext: {} 35 | # capabilities: 36 | # drop: 37 | # - ALL 38 | # readOnlyRootFilesystem: true 39 | # runAsNonRoot: true 40 | # runAsUser: 1000 41 | 42 | service: 43 | type: ClusterIP 44 | port: 80 45 | 46 | ingress: 47 | enabled: false 48 | className: "" 49 | annotations: {} 50 | # kubernetes.io/ingress.class: nginx 51 | # kubernetes.io/tls-acme: "true" 52 | hosts: 53 | - host: chart-example.local 54 | paths: 55 | - path: / 56 | pathType: ImplementationSpecific 57 | tls: [] 58 | # - secretName: chart-example-tls 59 | # hosts: 60 | # - chart-example.local 61 | 62 | resources: {} 63 | # We usually recommend not to specify default resources and to leave this as a conscious 64 | # choice for the user. This also increases chances charts run on environments with little 65 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 66 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 67 | # limits: 68 | # cpu: 100m 69 | # memory: 128Mi 70 | # requests: 71 | # cpu: 100m 72 | # memory: 128Mi 73 | 74 | livenessProbe: 75 | httpGet: 76 | path: / 77 | port: http 78 | readinessProbe: 79 | httpGet: 80 | path: / 81 | port: http 82 | 83 | autoscaling: 84 | enabled: false 85 | minReplicas: 1 86 | maxReplicas: 100 87 | targetCPUUtilizationPercentage: 80 88 | # targetMemoryUtilizationPercentage: 80 89 | 90 | # Additional volumes on the output Deployment definition. 91 | volumes: [] 92 | # - name: foo 93 | # secret: 94 | # secretName: mysecret 95 | # optional: false 96 | 97 | # Additional volumeMounts on the output Deployment definition. 98 | volumeMounts: [] 99 | # - name: foo 100 | # mountPath: "/etc/foo" 101 | # readOnly: true 102 | 103 | nodeSelector: {} 104 | 105 | tolerations: [] 106 | 107 | affinity: {} 108 | -------------------------------------------------------------------------------- /examples/helm/values/filtered.yaml: -------------------------------------------------------------------------------- 1 | fullnameOverride: super-app-name 2 | -------------------------------------------------------------------------------- /examples/helm/values/my-app-set.yaml: -------------------------------------------------------------------------------- 1 | fullnameOverride: super-app-name 2 | -------------------------------------------------------------------------------- /examples/helm/values/my-app.yaml: -------------------------------------------------------------------------------- 1 | fullnameOverride: super-app-name 2 | -------------------------------------------------------------------------------- /examples/ignore-annotation/app.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: ignore-annotation-example 5 | namespace: argocd 6 | annotations: 7 | argocd-diff-preview/ignore: "true" 8 | spec: 9 | project: default 10 | destination: 11 | name: in-cluster 12 | namespace: default 13 | sources: 14 | - repoURL: https://github.com/dag-andersen/argocd-diff-preview 15 | ref: local-files 16 | - path: examples/helm/charts/myApp 17 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 18 | helm: 19 | valueFiles: 20 | - $local-files/examples/helm/values/my-app.yaml 21 | -------------------------------------------------------------------------------- /examples/internal-chart/app.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: internal-chart-example 5 | namespace: argocd 6 | spec: 7 | project: default 8 | destination: 9 | name: in-cluster 10 | namespace: default 11 | sources: 12 | - repoURL: https://github.com/dag-andersen/argocd-diff-preview 13 | ref: local-files 14 | - path: examples/helm/charts/myApp 15 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 16 | helm: 17 | valueFiles: 18 | - $local-files/examples/helm/values/my-app.yaml 19 | -------------------------------------------------------------------------------- /examples/kustomize/applications/my-service-prod.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: my-service-production 5 | namespace: argocd 6 | spec: 7 | project: default 8 | destination: 9 | name: in-cluster 10 | namespace: default 11 | source: 12 | path: examples/kustomize/my-service/overlay/production 13 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 14 | kustomize: 15 | patches: 16 | - target: 17 | kind: Deployment 18 | patch: |- 19 | - op: replace 20 | path: /spec/template/spec/containers/0/ports/0/containerPort 21 | value: 443 22 | -------------------------------------------------------------------------------- /examples/kustomize/applications/my-service-staging.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: my-service-staging 5 | namespace: argocd 6 | spec: 7 | project: default 8 | destination: 9 | name: in-cluster 10 | namespace: default 11 | source: 12 | path: examples/kustomize/my-service/overlay/staging 13 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 14 | -------------------------------------------------------------------------------- /examples/kustomize/my-service/base/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: myapp 5 | spec: 6 | replicas: 2 7 | template: 8 | spec: 9 | containers: 10 | - name: myapp 11 | image: dag-andersen/myapp:latest 12 | ports: 13 | - containerPort: 80 14 | -------------------------------------------------------------------------------- /examples/kustomize/my-service/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - deployment.yaml 5 | - service.yaml 6 | labels: 7 | - includeSelectors: true 8 | pairs: 9 | app: myapp 10 | -------------------------------------------------------------------------------- /examples/kustomize/my-service/base/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: myapp 5 | spec: 6 | ports: 7 | - port: 80 8 | -------------------------------------------------------------------------------- /examples/kustomize/my-service/overlay/production/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namePrefix: prod- 4 | resources: 5 | - ../../base 6 | patches: 7 | - path: replica_count.yaml 8 | - path: memory_limit.yaml 9 | -------------------------------------------------------------------------------- /examples/kustomize/my-service/overlay/production/memory_limit.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: myapp 5 | spec: 6 | template: 7 | spec: 8 | containers: 9 | - name: myapp 10 | resources: 11 | limits: 12 | memory: "512Mi" 13 | requests: 14 | memory: "128Mi" 15 | -------------------------------------------------------------------------------- /examples/kustomize/my-service/overlay/production/replica_count.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: myapp 5 | spec: 6 | replicas: 3 7 | -------------------------------------------------------------------------------- /examples/kustomize/my-service/overlay/staging/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namePrefix: staging- 4 | resources: 5 | - ../../base 6 | patches: 7 | - path: memory_limit.yaml 8 | -------------------------------------------------------------------------------- /examples/kustomize/my-service/overlay/staging/memory_limit.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: myapp 5 | spec: 6 | template: 7 | spec: 8 | containers: 9 | - name: myapp 10 | resources: 11 | limits: 12 | memory: "256Mi" 13 | requests: 14 | memory: "128Mi" 15 | -------------------------------------------------------------------------------- /examples/label-selectors/app.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: label-selectors-example 5 | namespace: argocd 6 | labels: 7 | team: my-team 8 | spec: 9 | project: default 10 | destination: 11 | name: in-cluster 12 | namespace: default 13 | sources: 14 | - repoURL: https://github.com/dag-andersen/argocd-diff-preview 15 | ref: local-files 16 | - path: examples/helm/charts/myApp 17 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 18 | helm: 19 | valueFiles: 20 | - $local-files/examples/helm/values/filtered.yaml 21 | valuesObject: 22 | replicaCount: 5 23 | -------------------------------------------------------------------------------- /examples/list-generator/app/app-set-empty.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: ApplicationSet 3 | metadata: 4 | name: list-generator-example-appset-empty 5 | namespace: argocd 6 | spec: 7 | goTemplate: true 8 | goTemplateOptions: ["missingkey=error"] 9 | generators: 10 | - list: 11 | elements: [] 12 | template: 13 | metadata: 14 | name: "{{ .name }}" 15 | finalizers: 16 | - resources-finalizer.argocd.argoproj.io 17 | spec: 18 | project: default 19 | source: 20 | repoURL: https://github.com/dag-andersen/argocd-diff-preview.git 21 | path: "{{ .path }}" 22 | destination: 23 | name: in-cluster 24 | syncPolicy: 25 | syncOptions: 26 | - CreateNamespace=true 27 | -------------------------------------------------------------------------------- /examples/list-generator/app/app-set.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: ApplicationSet 3 | metadata: 4 | name: list-generator-example-appset 5 | namespace: argocd 6 | spec: 7 | goTemplate: true 8 | goTemplateOptions: ["missingkey=error"] 9 | generators: 10 | - list: 11 | elements: 12 | - name: app1 13 | path: examples/list-generator/resources 14 | template: 15 | metadata: 16 | name: "{{ .name }}" 17 | finalizers: 18 | - resources-finalizer.argocd.argoproj.io 19 | spec: 20 | project: default 21 | source: 22 | repoURL: https://github.com/dag-andersen/argocd-diff-preview.git 23 | path: "{{ .path }}" 24 | destination: 25 | name: in-cluster 26 | syncPolicy: 27 | syncOptions: 28 | - CreateNamespace=true 29 | -------------------------------------------------------------------------------- /examples/list-generator/resources/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: deploy-from-folder-one 5 | spec: 6 | replicas: 2 7 | selector: 8 | matchLabels: 9 | app: myapp 10 | template: 11 | metadata: 12 | labels: 13 | app: myapp 14 | spec: 15 | containers: 16 | - name: myapp 17 | image: dag-andersen/myapp:latest 18 | ports: 19 | - containerPort: 80 20 | -------------------------------------------------------------------------------- /examples/manifest-generate-paths/file-not-exist.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: manifest-generate-paths-file-not-exist-example 5 | namespace: argocd 6 | annotations: 7 | argocd.argoproj.io/manifest-generate-paths: "some-other-path;/examples/helm/values/some-other-file.yaml" 8 | spec: 9 | project: default 10 | destination: 11 | name: in-cluster 12 | namespace: default 13 | sources: 14 | - repoURL: https://github.com/dag-andersen/argocd-diff-preview 15 | ref: local-files 16 | - path: examples/helm/charts/myApp 17 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 18 | helm: 19 | valueFiles: 20 | - $local-files/examples/helm/values/filtered.yaml 21 | valuesObject: 22 | replicaCount: 5 23 | -------------------------------------------------------------------------------- /examples/manifest-generate-paths/valid-annotation.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: valid-manifest-generate-paths-example 5 | namespace: argocd 6 | annotations: 7 | argocd.argoproj.io/manifest-generate-paths: "some-other-path;/examples/helm/values/filtered.yaml" 8 | spec: 9 | project: default 10 | destination: 11 | name: in-cluster 12 | namespace: default 13 | sources: 14 | - repoURL: https://github.com/dag-andersen/argocd-diff-preview 15 | ref: local-files 16 | - path: examples/helm/charts/myApp 17 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 18 | helm: 19 | valueFiles: 20 | - $local-files/examples/helm/values/filtered.yaml 21 | valuesObject: 22 | replicaCount: 5 23 | -------------------------------------------------------------------------------- /examples/watch-pattern/valid-regex.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: watch-pattern-valid-regex-example 5 | namespace: argocd 6 | annotations: 7 | argocd-diff-preview/watch-pattern: "examples/helm/charts/myApp/.*, examples/helm/values/filtered" 8 | spec: 9 | project: default 10 | destination: 11 | name: in-cluster 12 | namespace: default 13 | sources: 14 | - repoURL: https://github.com/dag-andersen/argocd-diff-preview 15 | ref: local-files 16 | - path: examples/helm/charts/myApp 17 | repoURL: https://github.com/dag-andersen/argocd-diff-preview 18 | helm: 19 | valueFiles: 20 | - $local-files/examples/helm/values/filtered.yaml 21 | valuesObject: 22 | replicaCount: 5 23 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | gitops_repo ?= argocd-diff-preview 2 | github_org ?= dag-andersen 3 | base_branch := main 4 | docker_file := Dockerfile 5 | argocd_namespace := argocd-diff-preview 6 | timeout := 120 7 | VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 8 | COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") 9 | BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 10 | GO_TEST_FLAGS ?= 11 | 12 | go-build: 13 | go build -ldflags="-X 'main.Version=$(VERSION)' -X 'main.Commit=$(COMMIT)' -X 'main.BuildDate=$(BUILD_DATE)'" -o bin/argocd-diff-preview ./cmd 14 | 15 | pull-repository: 16 | @rm -rf base-branch || true && mkdir -p base-branch 17 | @rm -rf target-branch || true && mkdir -p target-branch 18 | cd base-branch && git clone https://github.com/$(github_org)/$(gitops_repo).git --depth=1 --branch "$(base_branch)" && cp -r $(gitops_repo)/. . && rm -rf .git && echo "*" > .gitignore && rm -rf $(gitops_repo) && cd - 19 | cd target-branch && git clone https://github.com/$(github_org)/$(gitops_repo).git --depth=1 --branch "$(target_branch)" && cp -r $(gitops_repo)/. . && rm -rf .git && echo "*" > .gitignore && rm -rf $(gitops_repo) && cd - 20 | 21 | docker-build: 22 | docker build . -f $(docker_file) -t image --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --build-arg BUILD_DATE=$(BUILD_DATE) 23 | 24 | run-with-go: go-build pull-repository 25 | ./bin/argocd-diff-preview \ 26 | --base-branch="$(base_branch)" \ 27 | --target-branch="$(target_branch)" \ 28 | --repo="$(github_org)/$(gitops_repo)" \ 29 | --debug \ 30 | --keep-cluster-alive \ 31 | --file-regex="$(regex)" \ 32 | --diff-ignore="$(diff_ignore)" \ 33 | --timeout=$(timeout) \ 34 | --selector="$(selector)" \ 35 | --argocd-namespace="$(argocd_namespace)" \ 36 | --files-changed="$(files_changed)" \ 37 | --line-count="$(line_count)" \ 38 | --redirect-target-revisions="HEAD" 39 | 40 | run-with-docker: pull-repository docker-build 41 | docker run \ 42 | --network=host \ 43 | -v ~/.kube:/root/.kube \ 44 | -v /var/run/docker.sock:/var/run/docker.sock \ 45 | -v $(PWD)/base-branch:/base-branch \ 46 | -v $(PWD)/target-branch:/target-branch \ 47 | -v $(PWD)/output:/output \ 48 | -v $(PWD)/secrets:/secrets \ 49 | -e BASE_BRANCH=$(base_branch) \ 50 | -e TARGET_BRANCH=$(target_branch) \ 51 | -e REPO=$(github_org)/$(gitops_repo) \ 52 | -e FILE_REGEX="$(regex)" \ 53 | -e DIFF_IGNORE="$(diff_ignore)" \ 54 | -e TIMEOUT=$(timeout) \ 55 | -e SELECTOR="$(selector)" \ 56 | -e FILES_CHANGED="$(files_changed)" \ 57 | -e LINE_COUNT="$(line_count)" \ 58 | -e MAX_DIFF_LENGTH="$(max_diff_length)" \ 59 | image \ 60 | --argocd-namespace="$(argocd_namespace)" 61 | 62 | mkdocs: 63 | python3 -m venv venv \ 64 | && source venv/bin/activate \ 65 | && pip3 install mkdocs-material \ 66 | && open http://localhost:8000 \ 67 | && mkdocs serve 68 | 69 | run-lint: 70 | golangci-lint run 71 | 72 | run-unit-tests: 73 | go test $(GO_TEST_FLAGS) ./... 74 | go test $(GO_TEST_FLAGS) -race ./... 75 | go test $(GO_TEST_FLAGS) -cover ./... 76 | # go test -coverprofile=coverage.out ./... 77 | # go tool cover -html=coverage.out 78 | 79 | run-integration-tests-docker: 80 | cd tests && $(MAKE) run-test-all-docker 81 | 82 | run-integration-tests-go: go-build 83 | cd tests && $(MAKE) run-test-all-go 84 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Argo CD Diff Preview 2 | repo_url: https://github.com/dag-andersen/argocd-diff-preview 3 | edit_uri: edit/main/docs/ 4 | theme: 5 | icon: 6 | repo: fontawesome/brands/github 7 | name: material 8 | logo: assets/logo-head.png 9 | favicon: assets/logo-head.png 10 | features: 11 | - content.action.edit 12 | palette: 13 | # Dark Mode 14 | scheme: slate 15 | primary: black 16 | accent: grey 17 | 18 | nav: 19 | - Overview: index.md 20 | - Demo: demo.md 21 | - Getting started: 22 | - github-actions-workflow.md 23 | - gitlab-cicd.md 24 | - Local Installation: installation.md 25 | - Self-hosted Github Runners: self-hosted-gh-runner.md 26 | - custom-argo-cd-installation.md 27 | - generated-applications.md 28 | - Ignore Lines: ignoring-lines.md 29 | - application-selection.md 30 | - All Options: options.md 31 | - Troubleshooting: troubleshooting.md 32 | - FAQ: faq.md 33 | 34 | markdown_extensions: 35 | - pymdownx.highlight: 36 | anchor_linenums: true 37 | line_spans: __span 38 | pygments_lang_class: true 39 | - pymdownx.inlinehilite 40 | - pymdownx.snippets 41 | - pymdownx.superfences 42 | - pymdownx.tabbed: 43 | alternate_style: true 44 | - admonition 45 | - pymdownx.details 46 | 47 | extra: 48 | social: 49 | - icon: fontawesome/brands/github 50 | link: https://github.com/dag-andersen/argocd-diff-preview 51 | 52 | copyright: Copyright © 2024 Dag Bjerre Andersen 53 | -------------------------------------------------------------------------------- /pkg/argoapplication/applications.go: -------------------------------------------------------------------------------- 1 | package argoapplication 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/rs/zerolog/log" 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | 9 | "github.com/dag-andersen/argocd-diff-preview/pkg/git" 10 | k8s "github.com/dag-andersen/argocd-diff-preview/pkg/k8s" 11 | "sigs.k8s.io/yaml" 12 | ) 13 | 14 | // ArgoResource represents an Argo CD Application or ApplicationSet 15 | type ArgoResource struct { 16 | Yaml *unstructured.Unstructured 17 | Kind ApplicationKind 18 | Id string // The ID is the name of the k8s resource 19 | Name string // The name is the original name of the Application 20 | FileName string 21 | } 22 | 23 | func (a *ArgoResource) GetLongName() string { 24 | return fmt.Sprintf("%s [%s]", a.Name, a.FileName) 25 | } 26 | 27 | // AsString returns the YAML representation of the resource 28 | func (a *ArgoResource) AsString() (string, error) { 29 | bytes, err := yaml.Marshal(a.Yaml) 30 | if err != nil { 31 | return "", fmt.Errorf("failed to marshal yaml: %w", err) 32 | } 33 | return string(bytes), nil 34 | } 35 | 36 | // GetApplicationsForBranches gets applications for both base and target branches 37 | func GetApplicationsForBranches( 38 | argocdNamespace string, 39 | baseBranch *git.Branch, 40 | targetBranch *git.Branch, 41 | fileRegex *string, 42 | filterOptions FilterOptions, 43 | repo string, 44 | redirectRevisions []string, 45 | ) ([]ArgoResource, []ArgoResource, error) { 46 | baseApps, err := getApplications( 47 | argocdNamespace, 48 | baseBranch, 49 | fileRegex, 50 | filterOptions, 51 | repo, 52 | redirectRevisions, 53 | ) 54 | if err != nil { 55 | return nil, nil, err 56 | } 57 | 58 | targetApps, err := getApplications( 59 | argocdNamespace, 60 | targetBranch, 61 | fileRegex, 62 | filterOptions, 63 | repo, 64 | redirectRevisions, 65 | ) 66 | if err != nil { 67 | return nil, nil, err 68 | } 69 | 70 | return baseApps, targetApps, nil 71 | } 72 | 73 | // getApplications gets applications for a single branch 74 | func getApplications( 75 | argocdNamespace string, 76 | branch *git.Branch, 77 | fileRegex *string, 78 | filterOptions FilterOptions, 79 | repo string, 80 | redirectRevisions []string, 81 | ) ([]ArgoResource, error) { 82 | log.Info().Str("branch", branch.Name).Msg("🤖 Fetching all files for branch") 83 | 84 | yamlFiles := k8s.GetYamlFiles(branch.FolderName(), fileRegex) 85 | log.Info().Str("branch", branch.Name).Msgf("🤖 Found %d files in dir %s", len(yamlFiles), branch.FolderName()) 86 | 87 | k8sResources := k8s.ParseYaml(branch.FolderName(), yamlFiles) 88 | log.Info().Str("branch", branch.Name).Msgf("🤖 Which resulted in %d k8sResources", len(k8sResources)) 89 | 90 | applications := FromResourceToApplication(k8sResources) 91 | 92 | if len(applications) == 0 { 93 | return []ArgoResource{}, nil 94 | } 95 | 96 | // filter applications 97 | log.Info().Str("branch", branch.Name).Msgf("🤖 Filtering %d Application[Sets]", len(applications)) 98 | applications = FilterAllWithLogging(applications, filterOptions, branch) 99 | 100 | if len(applications) == 0 { 101 | return []ArgoResource{}, nil 102 | } 103 | 104 | log.Info().Str("branch", branch.Name).Msgf("🤖 Patching %d Application[Sets]", len(applications)) 105 | 106 | applications, err := patchApplications( 107 | argocdNamespace, 108 | applications, 109 | branch, 110 | repo, 111 | redirectRevisions, 112 | ) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | log.Debug().Str("branch", branch.Name).Msgf("Patched %d Application[Sets]", len(applications)) 118 | 119 | return applications, nil 120 | } 121 | -------------------------------------------------------------------------------- /pkg/argoapplication/conversion.go: -------------------------------------------------------------------------------- 1 | package argoapplication 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/dag-andersen/argocd-diff-preview/pkg/k8s" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | // FromResourceToApplication converts K8sResources to ArgoResources with filtering 12 | func FromResourceToApplication( 13 | k8sResources []k8s.Resource, 14 | ) []ArgoResource { 15 | var apps []ArgoResource 16 | 17 | // Convert K8sResources to ArgoResources 18 | for _, r := range k8sResources { 19 | if app := fromK8sResource(r); app != nil { 20 | apps = append(apps, *app) 21 | } 22 | } 23 | 24 | return apps 25 | } 26 | 27 | // fromK8sResource creates an ArgoResource from a K8sResource 28 | func fromK8sResource(resource k8s.Resource) *ArgoResource { 29 | 30 | kind := resource.Yaml.GetKind() 31 | if kind == "" { 32 | log.Debug().Str("file", resource.FileName).Msg("No 'kind' field found in file") 33 | return nil 34 | } 35 | 36 | // Check if it's an Argo CD resource 37 | var appKind ApplicationKind 38 | switch kind { 39 | case "Application": 40 | appKind = Application 41 | case "ApplicationSet": 42 | appKind = ApplicationSet 43 | default: 44 | return nil 45 | } 46 | 47 | name := resource.Yaml.GetName() 48 | if name == "" { 49 | log.Debug().Str("file", resource.FileName).Msg("No 'metadata.name' field found in file") 50 | return nil 51 | } 52 | 53 | return &ArgoResource{ 54 | Yaml: &resource.Yaml, 55 | Kind: ApplicationKind(appKind), 56 | Id: name, 57 | Name: name, 58 | FileName: resource.FileName, 59 | } 60 | } 61 | 62 | // ApplicationsToString converts a slice of ArgoResource to a YAML string 63 | func ApplicationsToString(apps []ArgoResource) string { 64 | var yamlStrings []string 65 | for _, app := range apps { 66 | yamlStr, err := app.AsString() 67 | if err != nil { 68 | log.Debug().Err(err).Str(app.Kind.ShortName(), app.GetLongName()).Msg("Failed to convert app to YAML") 69 | continue 70 | } 71 | // add a comment with the name of the file 72 | yamlStr = fmt.Sprintf("# File: %s\n%s", app.FileName, yamlStr) 73 | 74 | yamlStrings = append(yamlStrings, yamlStr) 75 | } 76 | return strings.Join(yamlStrings, "---\n") 77 | } 78 | -------------------------------------------------------------------------------- /pkg/argoapplication/conversion_test.go: -------------------------------------------------------------------------------- 1 | package argoapplication 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/dag-andersen/argocd-diff-preview/pkg/k8s" 7 | "github.com/stretchr/testify/assert" 8 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 9 | "sigs.k8s.io/yaml" 10 | ) 11 | 12 | func TestFromK8sResource(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | resource *k8s.Resource 16 | want *ArgoResource 17 | wantErr bool 18 | }{ 19 | { 20 | name: "valid application", 21 | resource: &k8s.Resource{ 22 | FileName: "test.yaml", 23 | Yaml: func() unstructured.Unstructured { 24 | var obj unstructured.Unstructured 25 | err := yaml.Unmarshal([]byte(`apiVersion: argoproj.io/v1alpha1 26 | kind: Application 27 | metadata: 28 | name: test-app 29 | namespace: default 30 | spec: 31 | destination: 32 | namespace: default`), &obj) 33 | if err != nil { 34 | t.Fatalf("Failed to unmarshal YAML: %v", err) 35 | } 36 | return obj 37 | }(), 38 | }, 39 | want: &ArgoResource{ 40 | Kind: Application, 41 | Name: "test-app", 42 | Id: "test-app", 43 | FileName: "test.yaml", 44 | }, 45 | wantErr: false, 46 | }, 47 | { 48 | name: "valid application set", 49 | resource: &k8s.Resource{ 50 | FileName: "test-set.yaml", 51 | Yaml: func() unstructured.Unstructured { 52 | var obj unstructured.Unstructured 53 | err := yaml.Unmarshal([]byte(`apiVersion: argoproj.io/v1alpha1 54 | kind: ApplicationSet 55 | metadata: 56 | name: test-set 57 | namespace: default 58 | spec: 59 | generators: 60 | - git: 61 | repoURL: https://github.com/org/repo.git`), &obj) 62 | if err != nil { 63 | t.Fatalf("Failed to unmarshal YAML: %v", err) 64 | } 65 | return obj 66 | }(), 67 | }, 68 | want: &ArgoResource{ 69 | Kind: ApplicationSet, 70 | Name: "test-set", 71 | Id: "test-set", 72 | FileName: "test-set.yaml", 73 | }, 74 | wantErr: false, 75 | }, 76 | { 77 | name: "invalid kind", 78 | resource: &k8s.Resource{ 79 | FileName: "test.yaml", 80 | Yaml: func() unstructured.Unstructured { 81 | var obj unstructured.Unstructured 82 | err := yaml.Unmarshal([]byte(`apiVersion: argoproj.io/v1alpha1 83 | kind: InvalidKind 84 | metadata: 85 | name: test-app`), &obj) 86 | if err != nil { 87 | t.Fatalf("Failed to unmarshal YAML: %v", err) 88 | } 89 | return obj 90 | }(), 91 | }, 92 | want: nil, 93 | wantErr: true, 94 | }, 95 | { 96 | name: "missing metadata", 97 | resource: &k8s.Resource{ 98 | FileName: "test.yaml", 99 | Yaml: func() unstructured.Unstructured { 100 | var obj unstructured.Unstructured 101 | err := yaml.Unmarshal([]byte(`apiVersion: argoproj.io/v1alpha1 102 | kind: Application 103 | spec: 104 | destination: 105 | namespace: default`), &obj) 106 | if err != nil { 107 | t.Fatalf("Failed to unmarshal YAML: %v", err) 108 | } 109 | return obj 110 | }(), 111 | }, 112 | want: nil, 113 | wantErr: true, 114 | }, 115 | { 116 | name: "missing name", 117 | resource: &k8s.Resource{ 118 | FileName: "test.yaml", 119 | Yaml: func() unstructured.Unstructured { 120 | var obj unstructured.Unstructured 121 | err := yaml.Unmarshal([]byte(`apiVersion: argoproj.io/v1alpha1 122 | kind: Application 123 | metadata: 124 | namespace: default 125 | spec: 126 | destination: 127 | namespace: default`), &obj) 128 | if err != nil { 129 | t.Fatalf("Failed to unmarshal YAML: %v", err) 130 | } 131 | return obj 132 | }(), 133 | }, 134 | want: nil, 135 | wantErr: true, 136 | }, 137 | { 138 | name: "nil yaml", 139 | resource: &k8s.Resource{ 140 | FileName: "test.yaml", 141 | Yaml: unstructured.Unstructured{}, 142 | }, 143 | want: nil, 144 | wantErr: true, 145 | }, 146 | } 147 | 148 | for _, tt := range tests { 149 | t.Run(tt.name, func(t *testing.T) { 150 | got := fromK8sResource(*tt.resource) 151 | 152 | if tt.wantErr { 153 | assert.Nil(t, got) 154 | return 155 | } 156 | 157 | assert.NotNil(t, got) 158 | assert.Equal(t, tt.want.Kind, got.Kind) 159 | assert.Equal(t, tt.want.Id, got.Id) 160 | assert.Equal(t, tt.want.FileName, got.FileName) 161 | 162 | // Verify YAML structure 163 | assert.NotNil(t, got.Yaml) 164 | 165 | // Verify the yaml is equal 166 | assert.True(t, yamlEqual(&tt.resource.Yaml, got.Yaml)) 167 | }) 168 | } 169 | } 170 | 171 | func yamlEqual(a, b *unstructured.Unstructured) bool { 172 | 173 | aStr, err := yaml.Marshal(a) 174 | if err != nil { 175 | return false 176 | } 177 | bStr, err := yaml.Marshal(b) 178 | if err != nil { 179 | return false 180 | } 181 | 182 | return string(aStr) == string(bStr) 183 | } 184 | -------------------------------------------------------------------------------- /pkg/argoapplication/kind.go: -------------------------------------------------------------------------------- 1 | package argoapplication 2 | 3 | // ApplicationKind represents the type of Argo CD application 4 | type ApplicationKind int 5 | 6 | const ( 7 | Application ApplicationKind = iota 8 | ApplicationSet 9 | ) 10 | 11 | // ShortName returns the string representation of ApplicationKind 12 | func (k ApplicationKind) ShortName() string { 13 | switch k { 14 | case Application: 15 | return "App" 16 | case ApplicationSet: 17 | return "AppSet" 18 | default: 19 | return "Unknown" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/argoapplication/unique.go: -------------------------------------------------------------------------------- 1 | package argoapplication 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/dag-andersen/argocd-diff-preview/pkg/git" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | // UniqueIds ensures all applications have unique IDs by adding suffixes to duplicates 12 | func UniqueIds(apps []ArgoResource, branch *git.Branch) []ArgoResource { 13 | // Group applications by ID 14 | duplicateIds := make(map[string][]ArgoResource) 15 | for _, app := range apps { 16 | duplicateIds[app.Id] = append(duplicateIds[app.Id], app) 17 | } 18 | 19 | var newApps []ArgoResource 20 | duplicateCounter := 0 21 | 22 | // Process each group of applications 23 | for id, appsWithSameId := range duplicateIds { 24 | if len(appsWithSameId) > 1 { 25 | duplicateCounter++ 26 | log.Debug(). 27 | Str("branch", branch.Name). 28 | Msgf("Found %d duplicate applications with same name: %s", len(appsWithSameId), id) 29 | 30 | // Sort apps by filename for stable ordering 31 | sort.Slice(appsWithSameId, func(i, j int) bool { 32 | return appsWithSameId[i].FileName < appsWithSameId[j].FileName 33 | }) 34 | 35 | // Rename each app with a suffix 36 | for i, app := range appsWithSameId { 37 | newId := fmt.Sprintf("%s-%d", id, i+1) 38 | 39 | // Create a copy of the app 40 | newApp := app 41 | newApp.Id = newId 42 | 43 | // Update the name in the YAML 44 | newApp.Yaml.SetName(newId) 45 | log.Debug().Str("branch", branch.Name).Str(newApp.Kind.ShortName(), newApp.GetLongName()).Msgf("Updated name in yaml to: %s", newId) 46 | 47 | newApps = append(newApps, newApp) 48 | } 49 | } else { 50 | // No duplicates, keep as is 51 | newApps = append(newApps, appsWithSameId[0]) 52 | } 53 | } 54 | 55 | // sort newApps by filename 56 | sort.Slice(newApps, func(i, j int) bool { 57 | return newApps[i].Id < newApps[j].Id 58 | }) 59 | 60 | if duplicateCounter > 0 { 61 | log.Info(). 62 | Str("branch", branch.Name). 63 | Msgf("🔍 Found %d duplicate application names. Suffixing with -1, -2, -3, etc.", duplicateCounter) 64 | log.Info().Str("branch", branch.Name).Msgf("🤖 Applications after unique names: %v", len(newApps)) 65 | } 66 | 67 | return newApps 68 | } 69 | -------------------------------------------------------------------------------- /pkg/argoapplication/unique_test.go: -------------------------------------------------------------------------------- 1 | package argoapplication 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/dag-andersen/argocd-diff-preview/pkg/git" 8 | "github.com/stretchr/testify/assert" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | "sigs.k8s.io/yaml" 11 | ) 12 | 13 | func TestUniqueNames(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | apps []ArgoResource 17 | branch *git.Branch 18 | want []ArgoResource 19 | wantErr bool 20 | }{ 21 | { 22 | name: "no duplicates", 23 | apps: []ArgoResource{ 24 | createTestApp("app1", "app1.yaml"), 25 | createTestApp("app2", "app2.yaml"), 26 | createTestApp("app3", "app3.yaml"), 27 | }, 28 | branch: &git.Branch{Name: "test-branch"}, 29 | want: []ArgoResource{ 30 | createTestApp("app1", "app1.yaml"), 31 | createTestApp("app2", "app2.yaml"), 32 | createTestApp("app3", "app3.yaml"), 33 | }, 34 | wantErr: false, 35 | }, 36 | { 37 | name: "with duplicates", 38 | apps: []ArgoResource{ 39 | createTestApp("app1", "app1.yaml"), 40 | createTestApp("app1", "app1-2.yaml"), 41 | createTestApp("app2", "app2.yaml"), 42 | }, 43 | branch: &git.Branch{Name: "test-branch"}, 44 | want: []ArgoResource{ 45 | createTestApp("app1-1", "app1.yaml"), 46 | createTestApp("app1-2", "app1-2.yaml"), 47 | createTestApp("app2", "app2.yaml"), 48 | }, 49 | wantErr: false, 50 | }, 51 | { 52 | name: "multiple duplicates", 53 | apps: []ArgoResource{ 54 | createTestApp("app1", "app1.yaml"), 55 | createTestApp("app1", "app1-2.yaml"), 56 | createTestApp("app1", "app1-3.yaml"), 57 | createTestApp("app2", "app2.yaml"), 58 | createTestApp("app2", "app2-2.yaml"), 59 | }, 60 | branch: &git.Branch{Name: "test-branch"}, 61 | want: []ArgoResource{ 62 | createTestApp("app1-1", "app1.yaml"), 63 | createTestApp("app1-2", "app1-2.yaml"), 64 | createTestApp("app1-3", "app1-3.yaml"), 65 | createTestApp("app2-1", "app2.yaml"), 66 | createTestApp("app2-2", "app2-2.yaml"), 67 | }, 68 | wantErr: false, 69 | }, 70 | { 71 | name: "empty slice", 72 | apps: []ArgoResource{}, 73 | branch: &git.Branch{Name: "test-branch"}, 74 | want: []ArgoResource{}, 75 | wantErr: false, 76 | }, 77 | { 78 | name: "single app", 79 | apps: []ArgoResource{ 80 | createTestApp("app1", "app1.yaml"), 81 | }, 82 | branch: &git.Branch{Name: "test-branch"}, 83 | want: []ArgoResource{ 84 | createTestApp("app1", "app1.yaml"), 85 | }, 86 | wantErr: false, 87 | }, 88 | } 89 | 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | // Run UniqueNames 93 | got := UniqueIds(tt.apps, tt.branch) 94 | 95 | // Check length 96 | assert.Equal(t, len(tt.want), len(got), "Expected %d apps, got %d", len(tt.want), len(got)) 97 | 98 | // Check each app 99 | for i := range got { 100 | assert.Equal(t, tt.want[i].Id, got[i].Id, "App %d: Expected name %s, got %s", i, tt.want[i].Id, got[i].Id) 101 | 102 | // Check YAML name matches 103 | gotName := got[i].Yaml.GetName() 104 | assert.Equal(t, got[i].Id, gotName, "App %d: YAML name %s doesn't match struct name %s", i, gotName, got[i].Id) 105 | } 106 | 107 | // Check uniqueness 108 | names := make(map[string]bool) 109 | for _, app := range got { 110 | assert.False(t, names[app.Id], "Duplicate name found: %s", app.Id) 111 | names[app.Id] = true 112 | } 113 | }) 114 | } 115 | } 116 | 117 | // Helper function to create a test ArgoResource with basic YAML structure 118 | func createTestApp(name, fileName string) ArgoResource { 119 | yamlStr := ` 120 | apiVersion: argoproj.io/v1alpha1 121 | kind: Application 122 | metadata: 123 | name: ` + name + ` 124 | spec: 125 | destination: 126 | namespace: default` 127 | 128 | var y unstructured.Unstructured 129 | if err := yaml.Unmarshal([]byte(yamlStr), &y); err != nil { 130 | panic(fmt.Sprintf("failed to unmarshal yaml in test: %v", err)) 131 | } 132 | 133 | return ArgoResource{ 134 | Yaml: &y, 135 | Kind: Application, 136 | Id: name, 137 | FileName: fileName, 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /pkg/argocd/utils.go: -------------------------------------------------------------------------------- 1 | package argocd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/dag-andersen/argocd-diff-preview/pkg/utils" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // ApplySecretsFromFolder applies all secret manifests from a folder using the Kubernetes API 13 | func ApplySecretsFromFolder(client *utils.K8sClient, secretsFolder string, namespace string) error { 14 | // Check if folder exists 15 | if _, err := os.Stat(secretsFolder); os.IsNotExist(err) { 16 | log.Info().Msgf("🤷 No secrets folder found at %s", secretsFolder) 17 | return nil 18 | } 19 | 20 | // Apply all files in the secrets folder 21 | files, err := os.ReadDir(secretsFolder) 22 | if err != nil { 23 | return fmt.Errorf("failed to read secrets folder: %w", err) 24 | } 25 | 26 | secretCount := 0 27 | for _, file := range files { 28 | if file.IsDir() { 29 | continue 30 | } 31 | 32 | // Use the existing ApplyManifestFromFile method to apply each secret 33 | count, err := client.ApplyManifestFromFile(filepath.Join(secretsFolder, file.Name()), namespace) 34 | if err != nil { 35 | return fmt.Errorf("failed to apply secret %s: %w", file.Name(), err) 36 | } 37 | secretCount += count 38 | } 39 | 40 | if secretCount > 0 { 41 | log.Info().Msgf("🤫 Applied %d secrets", secretCount) 42 | } else { 43 | log.Info().Msgf("🤷 No secrets found in %s", secretsFolder) 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/cluster/types.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import "time" 4 | 5 | // Provider defines the interface for cluster management 6 | type Provider interface { 7 | GetName() string 8 | IsInstalled() bool 9 | CreateCluster() (time.Duration, error) 10 | ClusterExists() bool 11 | DeleteCluster(wait bool) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/diff/diff.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/go-git/go-git/v5/utils/merkletrie" 8 | ) 9 | 10 | type Diff struct { 11 | newName string 12 | oldName string 13 | newSourcePath string 14 | oldSourcePath string 15 | action merkletrie.Action 16 | content string 17 | } 18 | 19 | func (d *Diff) prettyName() string { 20 | switch { 21 | case d.newName != "" && d.oldName != "" && d.newName != d.oldName: 22 | return fmt.Sprintf("%s -> %s", d.oldName, d.newName) 23 | case d.newName != "": 24 | return d.newName 25 | case d.oldName != "": 26 | return d.oldName 27 | default: 28 | return "Unknown" 29 | } 30 | } 31 | 32 | func (d *Diff) prettyPath() string { 33 | switch { 34 | case d.newSourcePath != "" && d.oldSourcePath != "" && d.newSourcePath != d.oldSourcePath: 35 | return fmt.Sprintf("%s -> %s", d.oldSourcePath, d.newSourcePath) 36 | case d.newSourcePath != "": 37 | return d.newSourcePath 38 | case d.oldSourcePath != "": 39 | return d.oldSourcePath 40 | default: 41 | return "Unknown" 42 | } 43 | } 44 | 45 | func (d *Diff) commentHeader() string { 46 | switch d.action { 47 | case merkletrie.Insert: 48 | return fmt.Sprintf("@@ Application added: %s (%s) @@\n", d.prettyName(), d.prettyPath()) 49 | case merkletrie.Delete: 50 | return fmt.Sprintf("@@ Application deleted: %s (%s) @@\n", d.prettyName(), d.prettyPath()) 51 | case merkletrie.Modify: 52 | return fmt.Sprintf("@@ Application modified: %s (%s) @@\n", d.prettyName(), d.prettyPath()) 53 | default: 54 | return "" 55 | } 56 | } 57 | 58 | func (d *Diff) buildSection() string { 59 | header := fmt.Sprintf("%s (%s)", d.prettyName(), d.prettyPath()) 60 | 61 | content := strings.TrimSpace(fmt.Sprintf("%s%s", d.commentHeader(), d.content)) 62 | 63 | return fmt.Sprintf("<details>\n<summary>%s</summary>\n<br>\n\n```diff\n%s\n```\n\n</details>\n\n", header, content) 64 | } 65 | 66 | func (d *Diff) buildHTMLSection() string { 67 | header := fmt.Sprintf("%s (%s)", d.prettyName(), d.prettyPath()) 68 | return printHTMLSection(header, d.commentHeader(), d.content) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/diff/diff_test.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/go-git/go-git/v5/utils/merkletrie" 9 | ) 10 | 11 | func TestDiff_prettyName(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | diff Diff 15 | expected string 16 | }{ 17 | { 18 | name: "Both names, different", 19 | diff: Diff{newName: "new-app", oldName: "old-app"}, 20 | expected: "old-app -> new-app", 21 | }, 22 | { 23 | name: "Both names, same", 24 | diff: Diff{newName: "app", oldName: "app"}, 25 | expected: "app", 26 | }, 27 | { 28 | name: "Only new name", 29 | diff: Diff{newName: "new-app"}, 30 | expected: "new-app", 31 | }, 32 | { 33 | name: "Only old name", 34 | diff: Diff{oldName: "old-app"}, 35 | expected: "old-app", 36 | }, 37 | { 38 | name: "No names", 39 | diff: Diff{}, 40 | expected: "Unknown", 41 | }, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | if got := tt.diff.prettyName(); got != tt.expected { 47 | t.Errorf("prettyName() = %v, want %v", got, tt.expected) 48 | } 49 | }) 50 | } 51 | } 52 | 53 | func TestDiff_prettyPath(t *testing.T) { 54 | tests := []struct { 55 | name string 56 | diff Diff 57 | expected string 58 | }{ 59 | { 60 | name: "Both paths, different", 61 | diff: Diff{newSourcePath: "/path/to/new", oldSourcePath: "/path/to/old"}, 62 | expected: "/path/to/old -> /path/to/new", 63 | }, 64 | { 65 | name: "Both paths, same", 66 | diff: Diff{newSourcePath: "/path/to/app", oldSourcePath: "/path/to/app"}, 67 | expected: "/path/to/app", 68 | }, 69 | { 70 | name: "Only new path", 71 | diff: Diff{newSourcePath: "/path/to/new"}, 72 | expected: "/path/to/new", 73 | }, 74 | { 75 | name: "Only old path", 76 | diff: Diff{oldSourcePath: "/path/to/old"}, 77 | expected: "/path/to/old", 78 | }, 79 | { 80 | name: "No paths", 81 | diff: Diff{}, 82 | expected: "Unknown", 83 | }, 84 | } 85 | 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | if got := tt.diff.prettyPath(); got != tt.expected { 89 | t.Errorf("prettyPath() = %v, want %v", got, tt.expected) 90 | } 91 | }) 92 | } 93 | } 94 | 95 | func TestDiff_commentHeader(t *testing.T) { 96 | tests := []struct { 97 | name string 98 | diff Diff 99 | expected string 100 | }{ 101 | { 102 | name: "Insert", 103 | diff: Diff{newName: "app", newSourcePath: "/path", action: merkletrie.Insert}, 104 | expected: "@@ Application added: app (/path) @@\n", 105 | }, 106 | { 107 | name: "Delete", 108 | diff: Diff{oldName: "app", oldSourcePath: "/path", action: merkletrie.Delete}, 109 | expected: "@@ Application deleted: app (/path) @@\n", 110 | }, 111 | { 112 | name: "Modify", 113 | diff: Diff{newName: "app", newSourcePath: "/path", action: merkletrie.Modify}, 114 | expected: "@@ Application modified: app (/path) @@\n", 115 | }, 116 | { 117 | name: "Unknown action", 118 | diff: Diff{newName: "app", newSourcePath: "/path", action: 99}, // Assuming 99 is not a valid action 119 | expected: "", 120 | }, 121 | } 122 | 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | if got := tt.diff.commentHeader(); got != tt.expected { 126 | t.Errorf("commentHeader() = %q, want %q", got, tt.expected) 127 | } 128 | }) 129 | } 130 | } 131 | 132 | func TestDiff_buildSection(t *testing.T) { 133 | tests := []struct { 134 | name string 135 | diff Diff 136 | expectedFmt string // Use fmt string for easier comparison of structure 137 | }{ 138 | { 139 | name: "Insert", 140 | diff: Diff{ 141 | newName: "new-app", 142 | newSourcePath: "/path/new", 143 | action: merkletrie.Insert, 144 | content: "+ line 1\n+ line 2", 145 | }, 146 | expectedFmt: "<details>\n<summary>%s</summary>\n<br>\n\n```diff\n%s\n```\n\n</details>\n\n", 147 | }, 148 | { 149 | name: "Modify with name change", 150 | diff: Diff{ 151 | newName: "app-v2", 152 | oldName: "app-v1", 153 | newSourcePath: "/path/app", 154 | oldSourcePath: "/path/app", 155 | action: merkletrie.Modify, 156 | content: "- line 1\n+ line 1 mod", 157 | }, 158 | expectedFmt: "<details>\n<summary>%s</summary>\n<br>\n\n```diff\n%s\n```\n\n</details>\n\n", 159 | }, 160 | { 161 | name: "Delete", 162 | diff: Diff{ 163 | oldName: "old-app", 164 | oldSourcePath: "/path/old", 165 | action: merkletrie.Delete, 166 | content: "- line 1\n- line 2", 167 | }, 168 | expectedFmt: "<details>\n<summary>%s</summary>\n<br>\n\n```diff\n%s\n```\n\n</details>\n\n", 169 | }, 170 | } 171 | 172 | for _, tt := range tests { 173 | t.Run(tt.name, func(t *testing.T) { 174 | header := fmt.Sprintf("%s (%s)", tt.diff.prettyName(), tt.diff.prettyPath()) 175 | content := strings.TrimSpace(fmt.Sprintf("%s%s", tt.diff.commentHeader(), tt.diff.content)) 176 | expected := fmt.Sprintf(tt.expectedFmt, header, content) 177 | if got := tt.diff.buildSection(); got != expected { 178 | t.Errorf("buildSection() got =\n%v\nwant =\n%v", got, expected) 179 | } 180 | }) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /pkg/diff/format.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/go-git/go-git/v5/utils/diff" 10 | "github.com/sergi/go-diff/diffmatchpatch" 11 | ) 12 | 13 | // shouldIgnoreLine checks if a line should be ignored based on regex pattern 14 | func shouldIgnoreLine(line, pattern string) bool { 15 | matched, err := regexp.MatchString(pattern, line) 16 | if err != nil { 17 | // If regex fails, fall back to simple string matching 18 | return strings.Contains(line, pattern) 19 | } 20 | return matched 21 | } 22 | 23 | // formatDiff formats diffmatchpatch.Diff into unified diff format 24 | func formatDiff(diffs []diffmatchpatch.Diff, contextLines uint, ignorePattern *string) string { 25 | var buffer bytes.Buffer 26 | 27 | // Process the diffs and format them in unified diff format 28 | // We'll keep track of context lines to include only the specified number 29 | var processedLines []struct { 30 | prefix string 31 | text string 32 | isChange bool 33 | show bool 34 | } 35 | 36 | for _, d := range diffs { 37 | lines := strings.Split(d.Text, "\n") 38 | // If the last element is empty (due to trailing newline), remove it 39 | if len(lines) > 0 && lines[len(lines)-1] == "" { 40 | lines = lines[:len(lines)-1] 41 | } 42 | 43 | isChange := d.Type != diffmatchpatch.DiffEqual 44 | 45 | for _, line := range lines { 46 | // Determine if this line should be shown or filtered out 47 | show := true 48 | if isChange && ignorePattern != nil && *ignorePattern != "" { 49 | // Only apply regex filter to changed lines 50 | show = !shouldIgnoreLine(line, *ignorePattern) 51 | } 52 | 53 | prefix := " " 54 | switch d.Type { 55 | case diffmatchpatch.DiffDelete: 56 | prefix = "-" 57 | case diffmatchpatch.DiffInsert: 58 | prefix = "+" 59 | } 60 | 61 | processedLines = append(processedLines, struct { 62 | prefix string 63 | text string 64 | isChange bool 65 | show bool 66 | }{prefix, line, isChange, show}) 67 | } 68 | } 69 | 70 | // First find all changed lines that should be shown 71 | var changedLines []int 72 | for i, line := range processedLines { 73 | if line.isChange && line.show { 74 | changedLines = append(changedLines, i) 75 | } 76 | } 77 | 78 | // No changes to show, so return empty string 79 | if len(changedLines) == 0 { 80 | return "" 81 | } 82 | 83 | // Now create chunks of lines to include based on context 84 | var chunks []struct { 85 | start int 86 | end int 87 | } 88 | 89 | // Start with the first changed line and its context 90 | chunkStart := max(0, changedLines[0]-int(contextLines)) 91 | chunkEnd := min(len(processedLines)-1, changedLines[0]+int(contextLines)) 92 | 93 | // Extend chunk to include other changed lines that are within 2*contextLines 94 | for i := 1; i < len(changedLines); i++ { 95 | currentLine := changedLines[i] 96 | // If this changed line is close to our current chunk, extend the chunk 97 | if currentLine-chunkEnd <= 2*int(contextLines) { 98 | chunkEnd = min(len(processedLines)-1, currentLine+int(contextLines)) 99 | } else { 100 | // Otherwise, finish this chunk and start a new one 101 | chunks = append(chunks, struct { 102 | start int 103 | end int 104 | }{chunkStart, chunkEnd}) 105 | 106 | chunkStart = max(0, currentLine-int(contextLines)) 107 | chunkEnd = min(len(processedLines)-1, currentLine+int(contextLines)) 108 | } 109 | } 110 | 111 | // Add the last chunk 112 | chunks = append(chunks, struct { 113 | start int 114 | end int 115 | }{chunkStart, chunkEnd}) 116 | 117 | // Now build the output with separators between chunks 118 | var filteredLines []struct { 119 | prefix string 120 | text string 121 | } 122 | 123 | for i, chunk := range chunks { 124 | // Add all lines in this chunk 125 | for j := chunk.start; j <= chunk.end; j++ { 126 | filteredLines = append(filteredLines, struct { 127 | prefix string 128 | text string 129 | }{processedLines[j].prefix, processedLines[j].text}) 130 | } 131 | 132 | // Add separator if there's a next chunk and it's far enough away 133 | if i < len(chunks)-1 { 134 | nextChunk := chunks[i+1] 135 | skippedLines := nextChunk.start - chunk.end - 1 136 | 137 | if skippedLines > 0 { 138 | separator := fmt.Sprintf("@@ skipped %d lines (%d -> %d) @@", skippedLines, chunk.end+1, nextChunk.start-1) 139 | filteredLines = append(filteredLines, struct { 140 | prefix string 141 | text string 142 | }{"", separator}) 143 | } 144 | } 145 | } 146 | 147 | // Write the filtered lines 148 | for _, line := range filteredLines { 149 | if strings.HasPrefix(line.text, "@@ skipped") { 150 | buffer.WriteString(line.text + "\n") 151 | } else { 152 | buffer.WriteString(line.prefix + line.text + "\n") 153 | } 154 | } 155 | 156 | return buffer.String() 157 | } 158 | 159 | // Helper functions for min/max 160 | func min(a, b int) int { 161 | if a < b { 162 | return a 163 | } 164 | return b 165 | } 166 | 167 | func max(a, b int) int { 168 | if a > b { 169 | return a 170 | } 171 | return b 172 | } 173 | 174 | // formatNewFileDiff formats a diff for a new file using the go-git/utils/diff package 175 | func formatNewFileDiff(content string, contextLines uint, ignorePattern *string) string { 176 | // For new files, we diff from empty string to the content 177 | diffs := diff.Do("", content) 178 | return formatDiff(diffs, contextLines, ignorePattern) 179 | } 180 | 181 | // formatDeletedFileDiff formats a diff for a deleted file using the go-git/utils/diff package 182 | func formatDeletedFileDiff(content string, contextLines uint, ignorePattern *string) string { 183 | // For deleted files, we diff from the content to empty string 184 | diffs := diff.Do(content, "") 185 | return formatDiff(diffs, contextLines, ignorePattern) 186 | } 187 | 188 | // formatModifiedFileDiff formats a diff for a modified file using the go-git/utils/diff package 189 | func formatModifiedFileDiff(oldContent, newContent string, contextLines uint, ignorePattern *string) string { 190 | diffs := diff.Do(oldContent, newContent) 191 | return formatDiff(diffs, contextLines, ignorePattern) 192 | } 193 | -------------------------------------------------------------------------------- /pkg/diff/html.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | "strings" 7 | ) 8 | 9 | const htmlTemplate = ` 10 | <html> 11 | <head> 12 | <style> 13 | body { 14 | font-family: arial; 15 | } 16 | .container { 17 | margin: auto; 18 | width: 910px; 19 | } 20 | .diffs { 21 | margin: 20px 0 20px 0; 22 | } 23 | .diff_container { 24 | width: 910px; 25 | overflow-x: scroll; 26 | } 27 | table { 28 | font-family: monospace; 29 | border: solid 1px rgb(53, 44, 47); 30 | border-spacing: 0px; 31 | width: 100%; 32 | } 33 | tr.normal_line { 34 | background:rgb(239, 239, 239); 35 | } 36 | tr.added_line { 37 | background:rgb(169, 216, 184); 38 | } 39 | tr.removed_line { 40 | background:rgb(247, 149, 173); 41 | } 42 | tr.comment_line { 43 | background:rgb(197, 194, 194); 44 | } 45 | pre { 46 | margin: 0; 47 | padding: 0; 48 | } 49 | </style> 50 | </head> 51 | <body> 52 | <div class="container"> 53 | <h1>%title%</h1> 54 | 55 | <p>Summary:</p> 56 | <pre>%summary%</pre> 57 | 58 | <div class="diffs"> 59 | %app_diffs% 60 | </div> 61 | 62 | <pre>%info_box%</pre> 63 | </div> 64 | </body> 65 | </html> 66 | ` 67 | 68 | const htmlSection = ` 69 | <details> 70 | <summary> 71 | %header% 72 | </summary> 73 | <div class="diff_container"> 74 | <table> 75 | %rows% 76 | </table> 77 | </div> 78 | </details> 79 | ` 80 | 81 | const htmlLine = `<tr class="%s"><td><pre>%s</pre></td></tr>` 82 | 83 | func printHTMLDiff(title, summary, diff string, infoBox string) string { 84 | htmlDiff := strings.ReplaceAll(htmlTemplate, "%title%", title) 85 | htmlDiff = strings.ReplaceAll(htmlDiff, "%summary%", summary) 86 | htmlDiff = strings.ReplaceAll(htmlDiff, "%app_diffs%", diff) 87 | htmlDiff = strings.ReplaceAll(htmlDiff, "%info_box%", infoBox) 88 | return strings.TrimSpace(htmlDiff) + "\n" 89 | } 90 | 91 | func printHTMLSection(header string, commentHeader string, content string) string { 92 | s := strings.ReplaceAll(htmlSection, "%header%", html.EscapeString(header)) 93 | 94 | rows := fmt.Sprintf(htmlLine, "comment_line", html.EscapeString(commentHeader)) 95 | for _, line := range strings.Split(content, "\n") { 96 | if len(line) == 0 { 97 | rows += fmt.Sprintf(htmlLine, "normal_line", html.EscapeString(line)) 98 | continue 99 | } 100 | switch (line[0]) { 101 | case '@': 102 | rows += fmt.Sprintf(htmlLine, "comment_line", html.EscapeString(line)) 103 | case '-': 104 | rows += fmt.Sprintf(htmlLine, "removed_line", html.EscapeString(line)) 105 | case '+': 106 | rows += fmt.Sprintf(htmlLine, "added_line", html.EscapeString(line)) 107 | default: 108 | rows += fmt.Sprintf(htmlLine, "normal_line", html.EscapeString(line)) 109 | } 110 | } 111 | s = strings.ReplaceAll(s, "%rows%", rows) 112 | 113 | return s 114 | } 115 | -------------------------------------------------------------------------------- /pkg/diff/infobox.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type InfoBox struct { 9 | FullDuration time.Duration 10 | ExtractDuration time.Duration 11 | ArgoCDInstallationDuration time.Duration 12 | ClusterCreationDuration time.Duration 13 | ApplicationCount int 14 | } 15 | 16 | func (t InfoBox) String() string { 17 | return fmt.Sprintf("_Stats_:\n[Applications: %d], [Full Run: %s], [Rendering: %s], [Cluster: %s], [Argo CD: %s]", 18 | t.ApplicationCount, t.FullDuration.Round(time.Second), t.ExtractDuration.Round(time.Second), t.ClusterCreationDuration.Round(time.Second), t.ArgoCDInstallationDuration.Round(time.Second)) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/diff/markdown.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | const markdownTemplate = ` 8 | ## %title% 9 | 10 | Summary: 11 | ` + "```yaml" + ` 12 | %summary% 13 | ` + "```" + ` 14 | 15 | %app_diffs% 16 | 17 | %info_box% 18 | ` 19 | 20 | func markdownTemplateLength() int { 21 | template := strings.ReplaceAll(markdownTemplate, "%summary%", "") 22 | template = strings.ReplaceAll(template, "%app_diffs%", "") 23 | template = strings.ReplaceAll(template, "%title%", "") 24 | template = strings.ReplaceAll(template, "%info_box%", "") 25 | return len(template) 26 | } 27 | 28 | func printMarkdownDiff(title, summary, diff string, infoBox string) string { 29 | markdown := strings.ReplaceAll(markdownTemplate, "%title%", title) 30 | markdown = strings.ReplaceAll(markdown, "%summary%", summary) 31 | markdown = strings.ReplaceAll(markdown, "%app_diffs%", diff) 32 | markdown = strings.ReplaceAll(markdown, "%info_box%", infoBox) 33 | return strings.TrimSpace(markdown) + "\n" 34 | } 35 | -------------------------------------------------------------------------------- /pkg/diff/no_apps_found.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/dag-andersen/argocd-diff-preview/pkg/selector" 8 | "github.com/dag-andersen/argocd-diff-preview/pkg/utils" 9 | ) 10 | 11 | // Constants for markdown template 12 | const noAppsFoundTemplate = ` 13 | ## %title% 14 | 15 | %message% 16 | ` 17 | 18 | // WriteNoAppsFoundMessage writes a message to the output folder when no applications are found 19 | func WriteNoAppsFoundMessage( 20 | title string, 21 | outputFolder string, 22 | selectors []selector.Selector, 23 | changedFiles []string, 24 | ) error { 25 | message := getNoAppsFoundMessage(selectors, changedFiles) 26 | markdown := generateNoAppsFoundMarkdown(title, message) 27 | markdownPath := fmt.Sprintf("%s/diff.md", outputFolder) 28 | 29 | if err := utils.WriteFile(markdownPath, markdown); err != nil { 30 | return fmt.Errorf("failed to write no apps found message: %w", err) 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // generateNoAppsFoundMarkdown generates markdown from the message 37 | func generateNoAppsFoundMarkdown(title, message string) string { 38 | markdown := strings.ReplaceAll(noAppsFoundTemplate, "%title%", title) 39 | markdown = strings.ReplaceAll(markdown, "%message%", message) 40 | return strings.TrimSpace(markdown) 41 | } 42 | 43 | // getNoAppsFoundMessage generates an appropriate message based on selectors and changed files 44 | func getNoAppsFoundMessage(selectors []selector.Selector, changedFiles []string) string { 45 | selectorString := func(s []selector.Selector) string { 46 | var strs []string 47 | for _, selector := range s { 48 | strs = append(strs, selector.String()) 49 | } 50 | return strings.Join(strs, ",") 51 | } 52 | 53 | switch { 54 | case len(selectors) > 0 && len(changedFiles) > 0: 55 | return fmt.Sprintf( 56 | "Found no changed Applications that matched `%s` and watched these files: `%s`", 57 | selectorString(selectors), 58 | strings.Join(changedFiles, "`, `"), 59 | ) 60 | case len(selectors) > 0: 61 | return fmt.Sprintf( 62 | "Found no changed Applications that matched `%s`", 63 | selectorString(selectors), 64 | ) 65 | case len(changedFiles) > 0: 66 | return fmt.Sprintf( 67 | "Found no changed Applications that watched these files: `%s`", 68 | strings.Join(changedFiles, "`, `"), 69 | ) 70 | default: 71 | return "Found no Applications" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/diff/summary.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/go-git/go-git/v5/utils/merkletrie" 8 | ) 9 | 10 | func buildSummary(changedFiles []Diff) string { 11 | var summaryBuilder strings.Builder 12 | 13 | addedCount := 0 14 | deletedCount := 0 15 | modifiedCount := 0 16 | 17 | for _, diff := range changedFiles { 18 | switch diff.action { 19 | case merkletrie.Insert: 20 | addedCount++ 21 | case merkletrie.Delete: 22 | deletedCount++ 23 | case merkletrie.Modify: 24 | modifiedCount++ 25 | } 26 | } 27 | summaryBuilder.WriteString(fmt.Sprintf("Total: %d files changed\n", addedCount+deletedCount+modifiedCount)) 28 | 29 | if addedCount > 0 { 30 | summaryBuilder.WriteString(fmt.Sprintf("\nAdded (%d):\n", addedCount)) 31 | for _, diff := range changedFiles { 32 | if diff.action == merkletrie.Insert { 33 | summaryBuilder.WriteString(fmt.Sprintf("+ %s\n", diff.prettyName())) 34 | } 35 | } 36 | } 37 | 38 | if deletedCount > 0 { 39 | summaryBuilder.WriteString(fmt.Sprintf("\nDeleted (%d):\n", deletedCount)) 40 | for _, diff := range changedFiles { 41 | if diff.action == merkletrie.Delete { 42 | summaryBuilder.WriteString(fmt.Sprintf("- %s\n", diff.prettyName())) 43 | } 44 | } 45 | } 46 | 47 | if modifiedCount > 0 { 48 | summaryBuilder.WriteString(fmt.Sprintf("\nModified (%d):\n", modifiedCount)) 49 | for _, diff := range changedFiles { 50 | if diff.action == merkletrie.Modify { 51 | summaryBuilder.WriteString(fmt.Sprintf("± %s\n", diff.prettyName())) 52 | } 53 | } 54 | } 55 | 56 | return summaryBuilder.String() 57 | } 58 | -------------------------------------------------------------------------------- /pkg/duplicates/duplicates.go: -------------------------------------------------------------------------------- 1 | package duplicates 2 | 3 | import ( 4 | "github.com/dag-andersen/argocd-diff-preview/pkg/argoapplication" 5 | "github.com/dag-andersen/argocd-diff-preview/pkg/git" 6 | "github.com/rs/zerolog/log" 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | ) 9 | 10 | // RemoveDuplicates finds and filters out duplicate applications between base and target branches 11 | func RemoveDuplicates(baseApps, targetApps []argoapplication.ArgoResource) ([]argoapplication.ArgoResource, []argoapplication.ArgoResource) { 12 | // Find duplicates 13 | var duplicateYaml []*unstructured.Unstructured 14 | for _, baseApp := range baseApps { 15 | for _, targetApp := range targetApps { 16 | if baseApp.Id == targetApp.Id && yamlEqual(baseApp.Yaml, targetApp.Yaml) { 17 | log.Debug().Str(baseApp.Kind.ShortName(), baseApp.Name).Msg("Skipping application because it has not changed") 18 | duplicateYaml = append(duplicateYaml, baseApp.Yaml) 19 | break 20 | } 21 | } 22 | } 23 | 24 | if len(duplicateYaml) == 0 { 25 | return baseApps, targetApps 26 | } 27 | 28 | // Remove duplicates and log stats 29 | baseAppsBefore := len(baseApps) 30 | targetAppsBefore := len(targetApps) 31 | 32 | // Actually filter out the duplicates using the helper function 33 | baseApps = filterDuplicates(baseApps, duplicateYaml) 34 | targetApps = filterDuplicates(targetApps, duplicateYaml) 35 | 36 | log.Info().Str("branch", string(git.Base)).Msgf( 37 | "🤖 Skipped %d Application[Sets] because they have not changed after patching", 38 | baseAppsBefore-len(baseApps), 39 | ) 40 | 41 | log.Info().Str("branch", string(git.Target)).Msgf( 42 | "🤖 Skipped %d Application[Sets] because they have not changed after patching", 43 | targetAppsBefore-len(targetApps), 44 | ) 45 | 46 | log.Info().Str("branch", string(git.Base)).Msgf( 47 | "🤖 Using the remaining %d Application[Sets]", 48 | len(baseApps), 49 | ) 50 | 51 | log.Info().Str("branch", string(git.Target)).Msgf( 52 | "🤖 Using the remaining %d Application[Sets]", 53 | len(targetApps), 54 | ) 55 | 56 | return baseApps, targetApps 57 | } 58 | 59 | func filterDuplicates(apps []argoapplication.ArgoResource, duplicates []*unstructured.Unstructured) []argoapplication.ArgoResource { 60 | var filtered []argoapplication.ArgoResource 61 | for _, app := range apps { 62 | isDuplicate := false 63 | for _, dup := range duplicates { 64 | if yamlEqual(app.Yaml, dup) { 65 | isDuplicate = true 66 | break 67 | } 68 | } 69 | if !isDuplicate { 70 | filtered = append(filtered, app) 71 | } 72 | } 73 | return filtered 74 | } 75 | -------------------------------------------------------------------------------- /pkg/duplicates/utils.go: -------------------------------------------------------------------------------- 1 | package duplicates 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 5 | "sigs.k8s.io/yaml" 6 | ) 7 | 8 | func yamlEqual(a, b *unstructured.Unstructured) bool { 9 | 10 | aStr, err := yaml.Marshal(a) 11 | if err != nil { 12 | return false 13 | } 14 | bStr, err := yaml.Marshal(b) 15 | if err != nil { 16 | return false 17 | } 18 | 19 | return string(aStr) == string(bStr) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/git/branch.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "fmt" 4 | 5 | // Branch represents a git branch and its local folder 6 | type Branch struct { 7 | Name string 8 | folderName string 9 | branchType BranchType 10 | } 11 | 12 | // BranchType represents the type of branch (base or target) 13 | type BranchType string 14 | 15 | const ( 16 | // Base represents the base branch for comparison 17 | Base BranchType = "base" 18 | // Target represents the target branch for comparison 19 | Target BranchType = "target" 20 | ) 21 | 22 | // NewBranch creates a new Branch instance 23 | func NewBranch(name string, branchType BranchType) *Branch { 24 | return &Branch{ 25 | Name: name, 26 | folderName: fmt.Sprintf("%s-branch", branchType), 27 | branchType: branchType, 28 | } 29 | } 30 | 31 | // FolderName returns the folder name for the branch 32 | func (b *Branch) FolderName() string { 33 | return b.folderName 34 | } 35 | 36 | // Type returns the type of the branch 37 | func (b *Branch) Type() BranchType { 38 | return b.branchType 39 | } 40 | -------------------------------------------------------------------------------- /pkg/git/branch_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewBranch(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | branchName string 13 | branchType BranchType 14 | expected *Branch 15 | }{ 16 | { 17 | name: "base branch", 18 | branchName: "main", 19 | branchType: Base, 20 | expected: &Branch{ 21 | Name: "main", 22 | folderName: "base-branch", 23 | branchType: Base, 24 | }, 25 | }, 26 | { 27 | name: "target branch", 28 | branchName: "feature", 29 | branchType: Target, 30 | expected: &Branch{ 31 | Name: "feature", 32 | folderName: "target-branch", 33 | branchType: Target, 34 | }, 35 | }, 36 | } 37 | 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | branch := NewBranch(tt.branchName, tt.branchType) 41 | assert.Equal(t, tt.expected.Name, branch.Name) 42 | assert.Equal(t, tt.expected.folderName, branch.folderName) 43 | assert.Equal(t, tt.expected.branchType, branch.branchType) 44 | }) 45 | } 46 | } 47 | 48 | func TestBranchMethods(t *testing.T) { 49 | branch := NewBranch("main", Base) 50 | 51 | t.Run("FolderName", func(t *testing.T) { 52 | assert.Equal(t, "base-branch", branch.FolderName()) 53 | }) 54 | 55 | t.Run("Type", func(t *testing.T) { 56 | assert.Equal(t, Base, branch.Type()) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/k3d/k3d.go: -------------------------------------------------------------------------------- 1 | package k3d 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | "time" 9 | 10 | "github.com/dag-andersen/argocd-diff-preview/pkg/cluster" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | func (k *K3d) GetName() string { 15 | return "k3d" 16 | } 17 | 18 | // IsInstalled checks if the k3d binary is available in PATH. 19 | func IsInstalled() bool { 20 | _, err := exec.LookPath("k3d") 21 | if err != nil { 22 | log.Debug().Msg("k3d command not found in PATH") 23 | return false 24 | } 25 | return true 26 | } 27 | 28 | // CreateCluster creates a new k3d cluster with the given name and options. 29 | func CreateCluster(clusterName, k3dOptions string, wait time.Duration) (time.Duration, error) { 30 | startTime := time.Now() 31 | 32 | // Check if docker is running 33 | if output, err := runCommand("docker", "ps"); err != nil { 34 | log.Error().Msg("❌ Docker is not running") 35 | return time.Since(startTime), fmt.Errorf("docker is not running: %s", output) 36 | } 37 | 38 | log.Info().Msg("🚀 Creating k3d cluster...") 39 | 40 | // Delete existing cluster if it exists 41 | if output, err := runCommand("k3d", "cluster", "delete", clusterName); err != nil { 42 | return time.Since(startTime), fmt.Errorf("failed to delete existing cluster: %s", output) 43 | } 44 | 45 | // Create new cluster 46 | args := []string{"cluster", "create"} 47 | if strings.TrimSpace(k3dOptions) != "" { 48 | args = append(args, strings.Fields(k3dOptions)...) 49 | } 50 | args = append(args, clusterName) 51 | 52 | if output, err := runCommand("k3d", args...); err != nil { 53 | if strings.TrimSpace(k3dOptions) == "" { 54 | log.Error().Msg("❌ Failed to create cluster") 55 | } else { 56 | log.Error().Msgf("❌ Failed to create cluster with options: %s", k3dOptions) 57 | } 58 | return time.Since(startTime), fmt.Errorf("failed to create cluster: %s", output) 59 | } 60 | 61 | duration := time.Since(startTime) 62 | 63 | log.Info().Msgf("🚀 Cluster created successfully in %s", duration.Round(time.Second)) 64 | return duration, nil 65 | } 66 | 67 | // ClusterExists checks if a cluster with the given name exists by parsing JSON output. 68 | func ClusterExists(clusterName string) bool { 69 | // Request JSON output for reliable parsing 70 | output, err := runCommand("k3d", "cluster", "list", "--output", "json") 71 | if err != nil { 72 | log.Debug().Err(err).Msgf("Failed to list k3d clusters: %s", output) 73 | return false 74 | } 75 | 76 | // Define a struct to unmarshal the cluster name, we only need the name field. 77 | type k3dClusterInfo struct { 78 | Name string `json:"name"` 79 | } 80 | 81 | var clusters []k3dClusterInfo 82 | err = json.Unmarshal([]byte(output), &clusters) 83 | if err != nil { 84 | log.Error().Err(err).Msgf("Failed to unmarshal k3d cluster list JSON: %s", output) 85 | return false 86 | } 87 | 88 | for _, cluster := range clusters { 89 | if cluster.Name == clusterName { 90 | return true 91 | } 92 | } 93 | 94 | log.Debug().Msgf("❌ Cluster '%s' not found in: %s", clusterName, output) 95 | return false 96 | } 97 | 98 | // DeleteCluster deletes the k3d cluster with the given name 99 | func DeleteCluster(clusterName string, wait bool) { 100 | log.Info().Msg("💥 Deleting cluster...") 101 | 102 | if wait { 103 | if output, err := runCommand("k3d", "cluster", "delete", clusterName); err != nil { 104 | log.Error().Msgf("❌ Failed to delete cluster: %s", output) 105 | return 106 | } 107 | log.Info().Msg("💥 Cluster deleted successfully") 108 | } else { 109 | if output, err := runCommand("k3d", "cluster", "delete", clusterName); err != nil { 110 | log.Error().Msgf("❌ Failed to start cluster deletion: %s", output) 111 | } 112 | } 113 | } 114 | 115 | // runCommand executes a k3d command and returns its output or error. 116 | func runCommand(name string, args ...string) (string, error) { 117 | cmd := exec.Command(name, args...) 118 | output, err := cmd.CombinedOutput() 119 | return string(output), err 120 | } 121 | 122 | // ensure K3dCluster implements cluster.Provider 123 | var _ cluster.Provider = (*K3d)(nil) 124 | 125 | // K3d represents a k3d cluster configuration. 126 | type K3d struct { 127 | clusterName string 128 | k3dOptions string 129 | } 130 | 131 | func New(clusterName string, k3dOptions string) *K3d { 132 | return &K3d{clusterName: clusterName, k3dOptions: k3dOptions} 133 | } 134 | 135 | // Implement cluster.Provider interface 136 | func (k *K3d) IsInstalled() bool { 137 | return IsInstalled() 138 | } 139 | 140 | func (k *K3d) CreateCluster() (time.Duration, error) { 141 | return CreateCluster(k.clusterName, k.k3dOptions, 120*time.Second) 142 | } 143 | 144 | func (k *K3d) ClusterExists() bool { 145 | return ClusterExists(k.clusterName) 146 | } 147 | 148 | func (k *K3d) DeleteCluster(wait bool) { 149 | DeleteCluster(k.clusterName, wait) 150 | } 151 | -------------------------------------------------------------------------------- /pkg/k8s/parser.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/rs/zerolog/log" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "sigs.k8s.io/yaml" 13 | ) 14 | 15 | // GetYamlFiles gets all YAML files in a directory 16 | func GetYamlFiles(directory string, fileRegex *string) []string { 17 | log.Debug().Msgf("Fetching all files in dir: %s", directory) 18 | 19 | var yamlFiles []string 20 | err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { 21 | if err != nil { 22 | return err 23 | } 24 | if info.IsDir() { 25 | return nil 26 | } 27 | 28 | // Check if file has .yaml or .yml extension 29 | ext := filepath.Ext(path) 30 | if ext != ".yaml" && ext != ".yml" { 31 | return nil 32 | } 33 | 34 | // Convert path to relative path 35 | relPath, err := filepath.Rel(directory, path) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | // Check regex if provided 41 | if fileRegex != nil { 42 | matched, err := regexp.MatchString(*fileRegex, relPath) 43 | if err != nil || !matched { 44 | return nil 45 | } 46 | } 47 | 48 | yamlFiles = append(yamlFiles, relPath) 49 | return nil 50 | }) 51 | 52 | if err != nil { 53 | log.Error().Err(err).Msg("⚠️ Error reading directory") 54 | return []string{} 55 | } 56 | 57 | if fileRegex != nil { 58 | log.Debug().Msgf("Found %d yaml files in dir '%s' matching regex: %s", 59 | len(yamlFiles), directory, *fileRegex) 60 | } else { 61 | log.Debug().Msgf("Found %d yaml files in dir '%s'", 62 | len(yamlFiles), directory) 63 | } 64 | 65 | return yamlFiles 66 | } 67 | 68 | // ParseYaml parses YAML files into Resources 69 | func ParseYaml(dir string, files []string) []Resource { 70 | var resources []Resource 71 | 72 | for _, file := range files { 73 | log.Debug().Msgf("In dir '%s' found yaml file: %s", dir, file) 74 | 75 | // Open and read file 76 | f, err := os.Open(filepath.Join(dir, file)) 77 | if err != nil { 78 | log.Warn().Err(err).Msgf("⚠️ Failed to open file '%s'", file) 79 | continue 80 | } 81 | defer func() { 82 | if err := f.Close(); err != nil { 83 | log.Warn().Err(err).Msgf("⚠️ Failed to close file '%s'", file) 84 | } 85 | }() 86 | 87 | // Read file line by line and split on "---" 88 | var currentChunk strings.Builder 89 | scanner := bufio.NewScanner(f) 90 | 91 | for scanner.Scan() { 92 | line := scanner.Text() 93 | 94 | if line == "---" { 95 | // Process the current chunk if it's not empty 96 | if currentChunk.Len() > 0 { 97 | processYamlChunk(file, currentChunk.String(), &resources) 98 | } 99 | currentChunk.Reset() 100 | } else { 101 | currentChunk.WriteString(line) 102 | currentChunk.WriteString("\n") 103 | } 104 | } 105 | 106 | // Process the last chunk 107 | if currentChunk.Len() > 0 { 108 | processYamlChunk(file, currentChunk.String(), &resources) 109 | } 110 | } 111 | 112 | return resources 113 | } 114 | 115 | // processYamlChunk parses a YAML chunk into an unstructured.Unstructured 116 | // A chunk is a single YAML object, e.g. a Deployment, Service, etc. 117 | func processYamlChunk(filename, chunk string, resources *[]Resource) { 118 | // Skip empty chunks or chunks with only whitespace 119 | if strings.TrimSpace(chunk) == "" { 120 | return 121 | } 122 | 123 | // Create a new map to hold the parsed YAML 124 | var yamlObj map[string]interface{} 125 | err := yaml.Unmarshal([]byte(chunk), &yamlObj) 126 | if err != nil { 127 | log.Debug().Err(err).Msgf("⚠️ Failed to parse YAML in file '%s'", filename) 128 | return 129 | } 130 | 131 | // Skip empty objects 132 | if len(yamlObj) == 0 { 133 | return 134 | } 135 | 136 | // Convert the map to an unstructured.Unstructured 137 | yamlData := unstructured.Unstructured{Object: yamlObj} 138 | 139 | // Check if this is a valid Kubernetes resource 140 | apiVersion, found, _ := unstructured.NestedString(yamlObj, "apiVersion") 141 | kind, kindFound, _ := unstructured.NestedString(yamlObj, "kind") 142 | 143 | if !found || !kindFound || apiVersion == "" || kind == "" { 144 | log.Debug().Msgf("⚠️ Skipping invalid Kubernetes resource in file '%s' (missing apiVersion or kind)", filename) 145 | return 146 | } 147 | 148 | *resources = append(*resources, Resource{ 149 | FileName: filename, 150 | Yaml: yamlData, 151 | }) 152 | } 153 | -------------------------------------------------------------------------------- /pkg/k8s/parser_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | "sigs.k8s.io/yaml" 11 | ) 12 | 13 | func TestParser(t *testing.T) { 14 | // Create a temporary directory for testing 15 | tempDir, err := os.MkdirTemp("", "test") 16 | assert.NoError(t, err) 17 | defer func() { 18 | if err := os.RemoveAll(tempDir); err != nil { 19 | t.Logf("Failed to remove temporary directory: %v", err) 20 | } 21 | }() 22 | 23 | // Create test YAML files 24 | testFiles := []struct { 25 | name string 26 | content string 27 | }{ 28 | { 29 | name: "test1.yaml", 30 | content: `apiVersion: v1 31 | kind: Pod 32 | metadata: 33 | name: test-pod`, 34 | }, 35 | { 36 | name: "test2.yaml", 37 | content: `apiVersion: v1 38 | kind: Service 39 | metadata: 40 | name: test-service`, 41 | }, 42 | } 43 | 44 | for _, file := range testFiles { 45 | err := os.WriteFile(filepath.Join(tempDir, file.name), []byte(file.content), 0644) 46 | assert.NoError(t, err) 47 | } 48 | 49 | t.Run("GetYamlFiles", func(t *testing.T) { 50 | files := GetYamlFiles(tempDir, nil) 51 | assert.Len(t, files, 2) 52 | assert.Contains(t, files, "test1.yaml") 53 | assert.Contains(t, files, "test2.yaml") 54 | }) 55 | 56 | t.Run("ParseYaml", func(t *testing.T) { 57 | files := []string{"test1.yaml", "test2.yaml"} 58 | resources := ParseYaml(tempDir, files) 59 | assert.Len(t, resources, 2) 60 | }) 61 | 62 | t.Run("WithFileRegex", func(t *testing.T) { 63 | regex := "test1.yaml" 64 | files := GetYamlFiles(tempDir, ®ex) 65 | assert.Len(t, files, 1) 66 | assert.Contains(t, files, "test1.yaml") 67 | }) 68 | } 69 | 70 | func TestProcessYamlChunk(t *testing.T) { 71 | tests := []struct { 72 | name string 73 | filename string 74 | chunk string 75 | want []Resource 76 | wantLog string 77 | }{ 78 | { 79 | name: "valid application yaml", 80 | filename: "test.yaml", 81 | chunk: `apiVersion: argoproj.io/v1alpha1 82 | kind: Application 83 | metadata: 84 | name: test-app 85 | spec: 86 | destination: 87 | namespace: default 88 | server: https://kubernetes.default.svc`, 89 | want: []Resource{ 90 | { 91 | FileName: "test.yaml", 92 | Yaml: func() unstructured.Unstructured { 93 | var y unstructured.Unstructured 94 | err := yaml.Unmarshal([]byte(`apiVersion: argoproj.io/v1alpha1 95 | kind: Application 96 | metadata: 97 | name: test-app 98 | spec: 99 | destination: 100 | namespace: default 101 | server: https://kubernetes.default.svc`), &y) 102 | assert.NoError(t, err) 103 | return y 104 | }(), 105 | }, 106 | }, 107 | }, 108 | { 109 | name: "invalid yaml", 110 | filename: "invalid.yaml", 111 | chunk: "invalid: :", 112 | want: nil, 113 | wantLog: "⚠️ Failed to parse YAML in file 'invalid.yaml'", 114 | }, 115 | } 116 | 117 | for _, tt := range tests { 118 | t.Run(tt.name, func(t *testing.T) { 119 | var resources []Resource 120 | processYamlChunk(tt.filename, tt.chunk, &resources) 121 | 122 | if tt.want == nil { 123 | assert.Empty(t, resources) 124 | } else { 125 | assert.Equal(t, tt.want[0].FileName, resources[0].FileName) 126 | assert.Equal(t, tt.want[0].Yaml, resources[0].Yaml) 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pkg/k8s/resource.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 4 | 5 | // Resource represents a Kubernetes resource from a YAML file 6 | type Resource struct { 7 | FileName string 8 | Yaml unstructured.Unstructured 9 | } 10 | -------------------------------------------------------------------------------- /pkg/k8s/resource_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | ) 9 | 10 | func TestResource(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | fileName string 14 | yaml unstructured.Unstructured 15 | }{ 16 | { 17 | name: "simple resource", 18 | fileName: "test.yaml", 19 | yaml: unstructured.Unstructured{}, 20 | }, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | resource := Resource{ 26 | FileName: tt.fileName, 27 | Yaml: tt.yaml, 28 | } 29 | 30 | assert.Equal(t, tt.fileName, resource.FileName) 31 | assert.Equal(t, tt.yaml, resource.Yaml) 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pkg/kind/kind.go: -------------------------------------------------------------------------------- 1 | package kind 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | "time" 9 | 10 | "github.com/dag-andersen/argocd-diff-preview/pkg/utils" 11 | 12 | "github.com/rs/zerolog/log" 13 | 14 | "github.com/dag-andersen/argocd-diff-preview/pkg/cluster" 15 | ) 16 | 17 | func (k *Kind) GetName() string { 18 | return "kind" 19 | } 20 | 21 | // IsInstalled checks if kind is installed on the system 22 | func IsInstalled() bool { 23 | _, err := exec.LookPath("kind") 24 | return err == nil 25 | } 26 | 27 | // CreateCluster creates a new kind cluster with the given name, optional kindOptions, optionally using internal IP 28 | func CreateCluster(clusterName string, kindOptions string, internal bool) (time.Duration, error) { 29 | 30 | // timer 31 | start := time.Now() 32 | 33 | // Check if docker is running 34 | if output, err := runCommand("docker", "ps"); err != nil { 35 | log.Error().Msg("❌ Docker is not running") 36 | return time.Since(start), fmt.Errorf("docker is not running: %s", output) 37 | } 38 | 39 | log.Info().Msg("🚀 Creating kind cluster...") 40 | 41 | // Delete existing cluster if it exists 42 | if output, err := runCommand("kind", "delete", "cluster", "--name", clusterName); err != nil { 43 | return time.Since(start), fmt.Errorf("failed to delete existing cluster: %s", output) 44 | } 45 | 46 | // Create new cluster 47 | args := []string{"create", "cluster"} 48 | if strings.TrimSpace(kindOptions) != "" { 49 | args = append(args, strings.Fields(kindOptions)...) 50 | } 51 | args = append(args, "--name", clusterName) 52 | 53 | if output, err := runCommand("kind", args...); err != nil { 54 | if strings.TrimSpace(kindOptions) == "" { 55 | log.Error().Msg("❌ Failed to create cluster") 56 | } else { 57 | log.Error().Msgf("❌ Failed to create cluster with options: %s", kindOptions) 58 | } 59 | return time.Since(start), fmt.Errorf("failed to create cluster: %s", output) 60 | } 61 | 62 | if internal { 63 | log.Debug().Msg("Manually writing internal kubeconfig because --kind-internal flag is set") 64 | 65 | output, err := runCommand("kind", "get", "kubeconfig", "--internal", "--name", clusterName) 66 | if err != nil { 67 | return time.Since(start), fmt.Errorf("failed to get cluster kubeconfig: %s", output) 68 | } 69 | 70 | kubeconfigPath := utils.GetKubeConfigPath() 71 | err = os.WriteFile(kubeconfigPath, []byte(output), 0644) 72 | if err != nil { 73 | return time.Since(start), fmt.Errorf("failed to write cluster kubeconfig: %s", kubeconfigPath) 74 | } 75 | } 76 | 77 | duration := time.Since(start) 78 | 79 | log.Info().Msgf("🚀 Cluster created successfully in %s", duration.Round(time.Second)) 80 | return duration, nil 81 | } 82 | 83 | // ClusterExists checks if a cluster with the given name exists 84 | func ClusterExists(clusterName string) bool { 85 | output, err := runCommand("kind", "get", "clusters") 86 | if err != nil { 87 | return false 88 | } 89 | 90 | clusters := strings.Split(strings.TrimSpace(output), "\n") 91 | for _, cluster := range clusters { 92 | if cluster == clusterName { 93 | return true 94 | } 95 | } 96 | 97 | log.Error().Msgf("❌ Cluster '%s' not found in: %s", clusterName, output) 98 | return false 99 | } 100 | 101 | // DeleteCluster deletes the kind cluster with the given name 102 | func DeleteCluster(clusterName string, wait bool) { 103 | log.Info().Msg("💥 Deleting cluster...") 104 | 105 | if wait { 106 | if output, err := runCommand("kind", "delete", "cluster", "--name", clusterName); err != nil { 107 | log.Error().Msgf("❌ Failed to delete cluster: %s", output) 108 | return 109 | } 110 | log.Info().Msg("💥 Cluster deleted successfully") 111 | } else { 112 | if output, err := runCommand("kind", "delete", "cluster", "--name", clusterName); err != nil { 113 | log.Error().Msgf("❌ Failed to start cluster deletion: %s", output) 114 | } 115 | } 116 | } 117 | 118 | // runCommand executes a command and returns its output 119 | func runCommand(name string, args ...string) (string, error) { 120 | cmd := exec.Command(name, args...) 121 | output, err := cmd.CombinedOutput() 122 | return string(output), err 123 | } 124 | 125 | // ensure Kind implements cluster.Provider 126 | var _ cluster.Provider = (*Kind)(nil) 127 | 128 | type Kind struct { 129 | clusterName string 130 | kindOptions string 131 | kindInternal bool 132 | } 133 | 134 | func New(clusterName string, kindOptions string, kindInternal bool) *Kind { 135 | return &Kind{clusterName: clusterName, kindOptions: kindOptions, kindInternal: kindInternal} 136 | } 137 | 138 | // Implement cluster.Provider interface 139 | func (k *Kind) IsInstalled() bool { 140 | return IsInstalled() 141 | } 142 | 143 | func (k *Kind) CreateCluster() (time.Duration, error) { 144 | return CreateCluster(k.clusterName, k.kindOptions, k.kindInternal) 145 | } 146 | 147 | func (k *Kind) ClusterExists() bool { 148 | return ClusterExists(k.clusterName) 149 | } 150 | 151 | func (k *Kind) DeleteCluster(wait bool) { 152 | DeleteCluster(k.clusterName, wait) 153 | } 154 | -------------------------------------------------------------------------------- /pkg/minikube/minikube.go: -------------------------------------------------------------------------------- 1 | package minikube 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "time" 7 | 8 | "github.com/rs/zerolog/log" 9 | 10 | "github.com/dag-andersen/argocd-diff-preview/pkg/cluster" 11 | ) 12 | 13 | func (m *Minikube) GetName() string { 14 | return "minikube" 15 | } 16 | 17 | // IsInstalled checks if minikube is installed on the system 18 | func IsInstalled() bool { 19 | _, err := exec.LookPath("minikube") 20 | return err == nil 21 | } 22 | 23 | // CreateCluster creates a new minikube cluster 24 | func CreateCluster() (time.Duration, error) { 25 | 26 | startTime := time.Now() 27 | 28 | // Check if docker is running 29 | if output, err := runCommand("docker", "ps"); err != nil { 30 | log.Error().Msg("❌ Docker is not running") 31 | return time.Since(startTime), fmt.Errorf("docker is not running: %s", output) 32 | } 33 | 34 | log.Info().Msg("🚀 Creating minikube cluster...") 35 | 36 | // Delete existing cluster if it exists 37 | if output, err := runCommand("minikube", "delete"); err != nil { 38 | return time.Since(startTime), fmt.Errorf("failed to delete existing cluster: %s", output) 39 | } 40 | 41 | // Create new cluster 42 | if output, err := runCommand("minikube", "start"); err != nil { 43 | log.Error().Msg("❌ Failed to create cluster") 44 | return time.Since(startTime), fmt.Errorf("failed to create cluster: %s", output) 45 | } 46 | 47 | duration := time.Since(startTime) 48 | 49 | log.Info().Msgf("🚀 Cluster created successfully in %s", duration.Round(time.Second)) 50 | return duration, nil 51 | } 52 | 53 | // ClusterExists checks if a minikube cluster exists 54 | func ClusterExists() bool { 55 | _, err := runCommand("minikube", "status") 56 | return err == nil 57 | } 58 | 59 | // DeleteCluster deletes the minikube cluster 60 | func DeleteCluster(wait bool) { 61 | log.Info().Msg("💥 Deleting cluster...") 62 | 63 | if wait { 64 | output, err := runCommand("minikube", "delete") 65 | if err != nil { 66 | log.Error().Msgf("❌ Failed to delete cluster: %v", output) 67 | return 68 | } 69 | log.Info().Msgf("💥 Cluster deleted successfully: %s", output) 70 | } else { 71 | cmd := exec.Command("minikube", "delete") 72 | if err := cmd.Start(); err != nil { 73 | log.Error().Msgf("❌ Failed to start cluster deletion: %v", err) 74 | } 75 | } 76 | } 77 | 78 | // runCommand executes a command and returns its output 79 | func runCommand(name string, args ...string) (string, error) { 80 | cmd := exec.Command(name, args...) 81 | output, err := cmd.CombinedOutput() 82 | return string(output), err 83 | } 84 | 85 | // ensure Minikube implements cluster.Provider 86 | var _ cluster.Provider = (*Minikube)(nil) 87 | 88 | type Minikube struct{} 89 | 90 | func New() *Minikube { 91 | return &Minikube{} 92 | } 93 | 94 | // Implement cluster.Provider interface 95 | func (m *Minikube) IsInstalled() bool { 96 | return IsInstalled() 97 | } 98 | 99 | func (m *Minikube) CreateCluster() (time.Duration, error) { 100 | return CreateCluster() 101 | } 102 | 103 | func (m *Minikube) ClusterExists() bool { 104 | return ClusterExists() 105 | } 106 | 107 | func (m *Minikube) DeleteCluster(wait bool) { 108 | DeleteCluster(wait) 109 | } 110 | -------------------------------------------------------------------------------- /pkg/selector/selector.go: -------------------------------------------------------------------------------- 1 | package selector 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | // Operator represents the comparison operator for selectors 11 | type Operator int 12 | 13 | const ( 14 | // Eq represents the equality operator 15 | Eq Operator = iota 16 | // Ne represents the inequality operator 17 | Ne 18 | ) 19 | 20 | // String returns the string representation of the Operator 21 | func (o Operator) String() string { 22 | switch o { 23 | case Eq: 24 | return "=" 25 | case Ne: 26 | return "!=" 27 | default: 28 | return "unknown" 29 | } 30 | } 31 | 32 | // Selector represents a key-value selector with an operator 33 | type Selector struct { 34 | Key string 35 | Value string 36 | Operator Operator 37 | } 38 | 39 | // String returns the string representation of the Selector 40 | func (s *Selector) String() string { 41 | return fmt.Sprintf("%s%s%s", s.Key, s.Operator, s.Value) 42 | } 43 | 44 | // InvalidSelectorError represents an error in selector format 45 | type InvalidSelectorError struct { 46 | Selector string 47 | Reason string 48 | } 49 | 50 | func (e *InvalidSelectorError) Error() string { 51 | return fmt.Sprintf("invalid selector '%s': %s", e.Selector, e.Reason) 52 | } 53 | 54 | // FromString creates a new Selector from a string representation 55 | func FromString(s string) (*Selector, error) { 56 | notEqual := strings.Split(s, "!=") 57 | equalDouble := strings.Split(s, "==") 58 | equalSingle := strings.Split(s, "=") 59 | 60 | var selector *Selector 61 | 62 | switch { 63 | case len(notEqual) == 2: 64 | selector = &Selector{ 65 | Key: strings.TrimSpace(notEqual[0]), 66 | Value: strings.TrimSpace(notEqual[1]), 67 | Operator: Ne, 68 | } 69 | case len(equalDouble) == 2: 70 | selector = &Selector{ 71 | Key: strings.TrimSpace(equalDouble[0]), 72 | Value: strings.TrimSpace(equalDouble[1]), 73 | Operator: Eq, 74 | } 75 | case len(equalSingle) == 2: 76 | // Only use single equals if it's not actually a double equals 77 | if !strings.Contains(s, "==") { 78 | selector = &Selector{ 79 | Key: strings.TrimSpace(equalSingle[0]), 80 | Value: strings.TrimSpace(equalSingle[1]), 81 | Operator: Eq, 82 | } 83 | } else { 84 | log.Error().Msgf("❌ Invalid label selector format: %s", s) 85 | return nil, &InvalidSelectorError{ 86 | Selector: s, 87 | Reason: "invalid format", 88 | } 89 | } 90 | default: 91 | log.Error().Msgf("❌ Invalid label selector format: %s", s) 92 | return nil, &InvalidSelectorError{ 93 | Selector: s, 94 | Reason: "invalid format", 95 | } 96 | } 97 | 98 | // Validate selector 99 | if selector.Key == "" || selector.Value == "" { 100 | log.Error().Msgf("❌ Invalid label selector format: empty key or value: %s", s) 101 | return nil, &InvalidSelectorError{ 102 | Selector: s, 103 | Reason: "empty key or value", 104 | } 105 | } 106 | 107 | // Check for invalid characters in key 108 | if strings.Contains(selector.Key, "!") || strings.Contains(selector.Key, "=") { 109 | log.Error().Msgf("❌ Invalid label selector format: key contains invalid characters: %s", s) 110 | return nil, &InvalidSelectorError{ 111 | Selector: s, 112 | Reason: "key contains invalid characters", 113 | } 114 | } 115 | 116 | // Check for invalid characters in value 117 | if strings.Contains(selector.Value, "!") || strings.Contains(selector.Value, "=") { 118 | log.Error().Msgf("❌ Invalid label selector format: value contains invalid characters: %s", s) 119 | return nil, &InvalidSelectorError{ 120 | Selector: s, 121 | Reason: "value contains invalid characters", 122 | } 123 | } 124 | 125 | return selector, nil 126 | } 127 | -------------------------------------------------------------------------------- /pkg/selector/selector_test.go: -------------------------------------------------------------------------------- 1 | package selector 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestOperator_String(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | operator Operator 11 | want string 12 | }{ 13 | { 14 | name: "Equal operator", 15 | operator: Eq, 16 | want: "=", 17 | }, 18 | { 19 | name: "Not Equal operator", 20 | operator: Ne, 21 | want: "!=", 22 | }, 23 | { 24 | name: "Unknown operator", 25 | operator: 999, // Invalid operator 26 | want: "unknown", 27 | }, 28 | } 29 | 30 | for _, tt := range tests { 31 | t.Run(tt.name, func(t *testing.T) { 32 | if got := tt.operator.String(); got != tt.want { 33 | t.Errorf("Operator.String() = %v, want %v", got, tt.want) 34 | } 35 | }) 36 | } 37 | } 38 | 39 | func TestSelector_String(t *testing.T) { 40 | tests := []struct { 41 | name string 42 | selector *Selector 43 | want string 44 | }{ 45 | { 46 | name: "Equal operator selector", 47 | selector: &Selector{ 48 | Key: "app", 49 | Value: "myapp", 50 | Operator: Eq, 51 | }, 52 | want: "app=myapp", 53 | }, 54 | { 55 | name: "Not Equal operator selector", 56 | selector: &Selector{ 57 | Key: "env", 58 | Value: "prod", 59 | Operator: Ne, 60 | }, 61 | want: "env!=prod", 62 | }, 63 | } 64 | 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | if got := tt.selector.String(); got != tt.want { 68 | t.Errorf("Selector.String() = %v, want %v", got, tt.want) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func TestFromString(t *testing.T) { 75 | tests := []struct { 76 | name string 77 | input string 78 | want *Selector 79 | wantErr bool 80 | }{ 81 | { 82 | name: "Valid equal selector with =", 83 | input: "app=myapp", 84 | want: &Selector{ 85 | Key: "app", 86 | Value: "myapp", 87 | Operator: Eq, 88 | }, 89 | wantErr: false, 90 | }, 91 | { 92 | name: "Valid equal selector with ==", 93 | input: "app==myapp", 94 | want: &Selector{ 95 | Key: "app", 96 | Value: "myapp", 97 | Operator: Eq, 98 | }, 99 | wantErr: false, 100 | }, 101 | { 102 | name: "Valid not equal selector", 103 | input: "env!=prod", 104 | want: &Selector{ 105 | Key: "env", 106 | Value: "prod", 107 | Operator: Ne, 108 | }, 109 | wantErr: false, 110 | }, 111 | { 112 | name: "Invalid format - no operator", 113 | input: "invalid", 114 | want: nil, 115 | wantErr: true, 116 | }, 117 | { 118 | name: "Invalid format - empty key", 119 | input: "=value", 120 | want: nil, 121 | wantErr: true, 122 | }, 123 | { 124 | name: "Invalid format - empty value", 125 | input: "key=", 126 | want: nil, 127 | wantErr: true, 128 | }, 129 | { 130 | name: "Invalid format - key contains =", 131 | input: "key=value=extra", 132 | want: nil, 133 | wantErr: true, 134 | }, 135 | { 136 | name: "Invalid format - key contains !", 137 | input: "my!key=value", 138 | want: nil, 139 | wantErr: true, 140 | }, 141 | { 142 | name: "Invalid format - value contains =", 143 | input: "key=value=extra", 144 | want: nil, 145 | wantErr: true, 146 | }, 147 | { 148 | name: "Invalid format - value contains !", 149 | input: "key=value!", 150 | want: nil, 151 | wantErr: true, 152 | }, 153 | } 154 | 155 | for _, tt := range tests { 156 | t.Run(tt.name, func(t *testing.T) { 157 | got, err := FromString(tt.input) 158 | if (err != nil) != tt.wantErr { 159 | t.Errorf("FromString() error = %v, wantErr %v", err, tt.wantErr) 160 | return 161 | } 162 | if tt.wantErr { 163 | return 164 | } 165 | if got.Key != tt.want.Key || got.Value != tt.want.Value || got.Operator != tt.want.Operator { 166 | t.Errorf("FromString() = %v, want %v", got, tt.want) 167 | } 168 | }) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /pkg/utils/k8s.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "k8s.io/client-go/util/homedir" 5 | "path/filepath" 6 | ) 7 | 8 | func GetKubeConfigPath() string { 9 | if home := homedir.HomeDir(); home != "" { 10 | return filepath.Join(home, ".kube", "config") 11 | } 12 | return "" 13 | } 14 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | const ( 13 | dirMode = os.ModePerm // 0755 - read/write/execute for owner, read/execute for group and others 14 | ) 15 | 16 | // WriteFile writes content to a file 17 | func WriteFile(path string, content string) error { 18 | // Ensure content ends with a newline 19 | if len(content) > 0 && !strings.HasSuffix(content, "\n") { 20 | content += "\n" 21 | } 22 | 23 | // Create directory if it doesn't exist 24 | dir := filepath.Dir(path) 25 | if err := os.MkdirAll(dir, 0755); err != nil { 26 | return fmt.Errorf("failed to create directory %s: %w", dir, err) 27 | } 28 | 29 | // Write file 30 | if err := os.WriteFile(path, []byte(content), 0644); err != nil { 31 | return fmt.Errorf("failed to write file %s: %w", path, err) 32 | } 33 | 34 | return nil 35 | } 36 | 37 | // Create folder (clear its content if it exists) 38 | func CreateFolder(path string, override bool) error { 39 | if override { 40 | if err := os.RemoveAll(path); err != nil { 41 | log.Debug().Str("path", path).Msgf("⚠️ Failed to delete folder: %s", err) 42 | } 43 | } 44 | err := os.MkdirAll(path, dirMode) 45 | if err != nil { 46 | log.Debug().Str("path", path).Msgf("❌ Failed to create folder: %s", err) 47 | } 48 | return err 49 | } 50 | -------------------------------------------------------------------------------- /tests/integration-test/branch-1/target/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Summary: 4 | ```yaml 5 | Total: 2 files changed 6 | 7 | Deleted (1): 8 | - folder2 9 | 10 | Modified (1): 11 | ± nginx-ingress 12 | ``` 13 | 14 | <details> 15 | <summary>folder2 (examples/git-generator/app/app-set.yaml)</summary> 16 | <br> 17 | 18 | ```diff 19 | @@ Application deleted: folder2 (examples/git-generator/app/app-set.yaml) @@ 20 | -apiVersion: apps/v1 21 | -kind: Deployment 22 | -metadata: 23 | - annotations: 24 | - argocd.argoproj.io/tracking-id: folder2:apps/Deployment:/deploy-from-folder-two 25 | - name: deploy-from-folder-two 26 | -spec: 27 | - replicas: 2 28 | - selector: 29 | - matchLabels: 30 | - app: myapp 31 | - template: 32 | - metadata: 33 | - labels: 34 | - app: myapp 35 | - spec: 36 | - containers: 37 | - - image: dag-andersen/myapp:latest 38 | - name: myapp 39 | - ports: 40 | - - containerPort: 80 41 | ``` 42 | 43 | </details> 44 | 45 | <details> 46 | <summary>nginx-ingress (examples/helm/applications/nginx.yaml)</summary> 47 | <br> 48 | 49 | ```diff 50 | @@ Application modified: nginx-ingress (examples/helm/applications/nginx.yaml) @@ 51 | app.kubernetes.io/managed-by: Helm 52 | app.kubernetes.io/name: ingress-nginx 53 | app.kubernetes.io/part-of: ingress-nginx 54 | - app.kubernetes.io/version: 1.10.0 55 | - helm.sh/chart: ingress-nginx-4.10.0 56 | + app.kubernetes.io/version: 1.11.1 57 | + helm.sh/chart: ingress-nginx-4.11.1 58 | name: nginx-ingress-ingress-nginx-controller 59 | namespace: default 60 | 61 | @@ skipped 9 lines (20 -> 28) @@ 62 | app.kubernetes.io/managed-by: Helm 63 | app.kubernetes.io/name: ingress-nginx 64 | app.kubernetes.io/part-of: ingress-nginx 65 | - app.kubernetes.io/version: 1.10.0 66 | - helm.sh/chart: ingress-nginx-4.10.0 67 | + app.kubernetes.io/version: 1.11.1 68 | + helm.sh/chart: ingress-nginx-4.11.1 69 | name: nginx-ingress-ingress-nginx-controller 70 | namespace: default 71 | spec: 72 | @@ skipped 29 lines (39 -> 67) @@ 73 | app.kubernetes.io/managed-by: Helm 74 | app.kubernetes.io/name: ingress-nginx 75 | app.kubernetes.io/part-of: ingress-nginx 76 | - app.kubernetes.io/version: 1.10.0 77 | - helm.sh/chart: ingress-nginx-4.10.0 78 | + app.kubernetes.io/version: 1.11.1 79 | + helm.sh/chart: ingress-nginx-4.11.1 80 | name: nginx-ingress-ingress-nginx-controller-admission 81 | namespace: default 82 | spec: 83 | @@ skipped 21 lines (78 -> 98) @@ 84 | app.kubernetes.io/managed-by: Helm 85 | app.kubernetes.io/name: ingress-nginx 86 | app.kubernetes.io/part-of: ingress-nginx 87 | - app.kubernetes.io/version: 1.10.0 88 | - helm.sh/chart: ingress-nginx-4.10.0 89 | + app.kubernetes.io/version: 1.11.1 90 | + helm.sh/chart: ingress-nginx-4.11.1 91 | name: nginx-ingress-ingress-nginx 92 | namespace: default 93 | 94 | @@ skipped 9 lines (109 -> 117) @@ 95 | app.kubernetes.io/managed-by: Helm 96 | app.kubernetes.io/name: ingress-nginx 97 | app.kubernetes.io/part-of: ingress-nginx 98 | - app.kubernetes.io/version: 1.10.0 99 | - helm.sh/chart: ingress-nginx-4.10.0 100 | + app.kubernetes.io/version: 1.11.1 101 | + helm.sh/chart: ingress-nginx-4.11.1 102 | name: nginx-ingress-ingress-nginx-admission 103 | webhooks: 104 | - admissionReviewVersions: 105 | @@ skipped 30 lines (128 -> 157) @@ 106 | app.kubernetes.io/managed-by: Helm 107 | app.kubernetes.io/name: ingress-nginx 108 | app.kubernetes.io/part-of: ingress-nginx 109 | - app.kubernetes.io/version: 1.10.0 110 | - helm.sh/chart: ingress-nginx-4.10.0 111 | + app.kubernetes.io/version: 1.11.1 112 | + helm.sh/chart: ingress-nginx-4.11.1 113 | name: nginx-ingress-ingress-nginx-controller 114 | namespace: default 115 | spec: 116 | @@ skipped 13 lines (168 -> 180) @@ 117 | app.kubernetes.io/managed-by: Helm 118 | app.kubernetes.io/name: ingress-nginx 119 | app.kubernetes.io/part-of: ingress-nginx 120 | - app.kubernetes.io/version: 1.10.0 121 | - helm.sh/chart: ingress-nginx-4.10.0 122 | + app.kubernetes.io/version: 1.11.1 123 | + helm.sh/chart: ingress-nginx-4.11.1 124 | spec: 125 | containers: 126 | - args: 127 | @@ skipped 99 lines (191 -> 289) @@ 128 | app.kubernetes.io/managed-by: Helm 129 | app.kubernetes.io/name: ingress-nginx 130 | app.kubernetes.io/part-of: ingress-nginx 131 | - app.kubernetes.io/version: 1.10.0 132 | - helm.sh/chart: ingress-nginx-4.10.0 133 | + app.kubernetes.io/version: 1.11.1 134 | + helm.sh/chart: ingress-nginx-4.11.1 135 | name: nginx 136 | spec: 137 | controller: k8s.io/ingress-nginx 138 | @@ skipped 9 lines (300 -> 308) @@ 139 | app.kubernetes.io/managed-by: Helm 140 | app.kubernetes.io/name: ingress-nginx 141 | app.kubernetes.io/part-of: ingress-nginx 142 | - app.kubernetes.io/version: 1.10.0 143 | - helm.sh/chart: ingress-nginx-4.10.0 144 | + app.kubernetes.io/version: 1.11.1 145 | + helm.sh/chart: ingress-nginx-4.11.1 146 | name: nginx-ingress-ingress-nginx 147 | rules: 148 | - apiGroups: 149 | @@ skipped 78 lines (319 -> 396) @@ 150 | app.kubernetes.io/managed-by: Helm 151 | app.kubernetes.io/name: ingress-nginx 152 | app.kubernetes.io/part-of: ingress-nginx 153 | - app.kubernetes.io/version: 1.10.0 154 | - helm.sh/chart: ingress-nginx-4.10.0 155 | + app.kubernetes.io/version: 1.11.1 156 | + helm.sh/chart: ingress-nginx-4.11.1 157 | name: nginx-ingress-ingress-nginx 158 | roleRef: 159 | apiGroup: rbac.authorization.k8s.io 160 | @@ skipped 16 lines (407 -> 422) @@ 161 | app.kubernetes.io/managed-by: Helm 162 | app.kubernetes.io/name: ingress-nginx 163 | app.kubernetes.io/part-of: ingress-nginx 164 | - app.kubernetes.io/version: 1.10.0 165 | - helm.sh/chart: ingress-nginx-4.10.0 166 | + app.kubernetes.io/version: 1.11.1 167 | + helm.sh/chart: ingress-nginx-4.11.1 168 | name: nginx-ingress-ingress-nginx 169 | namespace: default 170 | rules: 171 | @@ skipped 87 lines (433 -> 519) @@ 172 | app.kubernetes.io/managed-by: Helm 173 | app.kubernetes.io/name: ingress-nginx 174 | app.kubernetes.io/part-of: ingress-nginx 175 | - app.kubernetes.io/version: 1.10.0 176 | - helm.sh/chart: ingress-nginx-4.10.0 177 | + app.kubernetes.io/version: 1.11.1 178 | + helm.sh/chart: ingress-nginx-4.11.1 179 | name: nginx-ingress-ingress-nginx 180 | namespace: default 181 | roleRef: 182 | ``` 183 | 184 | </details> 185 | 186 | _Stats_: 187 | [], [], [], [], [] 188 | -------------------------------------------------------------------------------- /tests/integration-test/branch-2/target/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Summary: 4 | ```yaml 5 | Total: 1 files changed 6 | 7 | Modified (1): 8 | ± my-app 9 | ``` 10 | 11 | <details> 12 | <summary>my-app (examples/helm/applications/my-app.yaml)</summary> 13 | <br> 14 | 15 | ```diff 16 | @@ Application modified: my-app (examples/helm/applications/my-app.yaml) @@ 17 | apiVersion: v1 18 | kind: Service 19 | metadata: 20 | annotations: 21 | - argocd.argoproj.io/tracking-id: my-app:/Service:default/super-app-name 22 | + argocd.argoproj.io/tracking-id: my-app:/Service:default/new-app-name 23 | labels: 24 | app.kubernetes.io/instance: my-app 25 | app.kubernetes.io/managed-by: Helm 26 | app.kubernetes.io/name: myApp 27 | app.kubernetes.io/version: 1.16.0 28 | helm.sh/chart: myApp-0.1.0 29 | - name: super-app-name 30 | + name: new-app-name 31 | namespace: default 32 | spec: 33 | ports: 34 | - name: http 35 | port: 80 36 | protocol: TCP 37 | targetPort: http 38 | selector: 39 | app.kubernetes.io/instance: my-app 40 | app.kubernetes.io/name: myApp 41 | type: ClusterIP 42 | 43 | --- 44 | apiVersion: v1 45 | automountServiceAccountToken: true 46 | kind: ServiceAccount 47 | metadata: 48 | annotations: 49 | - argocd.argoproj.io/tracking-id: my-app:/ServiceAccount:default/super-app-name 50 | + argocd.argoproj.io/tracking-id: my-app:/ServiceAccount:default/new-app-name 51 | labels: 52 | app.kubernetes.io/instance: my-app 53 | app.kubernetes.io/managed-by: Helm 54 | app.kubernetes.io/name: myApp 55 | app.kubernetes.io/version: 1.16.0 56 | helm.sh/chart: myApp-0.1.0 57 | - name: super-app-name 58 | + name: new-app-name 59 | namespace: default 60 | 61 | --- 62 | apiVersion: apps/v1 63 | kind: Deployment 64 | metadata: 65 | annotations: 66 | - argocd.argoproj.io/tracking-id: my-app:apps/Deployment:default/super-app-name 67 | + argocd.argoproj.io/tracking-id: my-app:apps/Deployment:default/new-app-name 68 | labels: 69 | app.kubernetes.io/instance: my-app 70 | app.kubernetes.io/managed-by: Helm 71 | app.kubernetes.io/name: myApp 72 | app.kubernetes.io/version: 1.16.0 73 | helm.sh/chart: myApp-0.1.0 74 | - name: super-app-name 75 | + name: new-app-name 76 | namespace: default 77 | spec: 78 | - replicas: 1 79 | + replicas: 5 80 | selector: 81 | matchLabels: 82 | app.kubernetes.io/instance: my-app 83 | app.kubernetes.io/name: myApp 84 | template: 85 | metadata: 86 | labels: 87 | app.kubernetes.io/instance: my-app 88 | app.kubernetes.io/managed-by: Helm 89 | app.kubernetes.io/name: myApp 90 | @@ skipped 12 lines (73 -> 84) @@ 91 | - containerPort: 80 92 | name: http 93 | protocol: TCP 94 | readinessProbe: 95 | httpGet: 96 | path: / 97 | port: http 98 | resources: {} 99 | securityContext: {} 100 | securityContext: {} 101 | - serviceAccountName: super-app-name 102 | + serviceAccountName: new-app-name 103 | ``` 104 | 105 | </details> 106 | 107 | _Stats_: 108 | [], [], [], [], [] 109 | -------------------------------------------------------------------------------- /tests/integration-test/branch-3/target/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Summary: 4 | ```yaml 5 | Total: 1 files changed 6 | 7 | Modified (1): 8 | ± my-service-staging 9 | ``` 10 | 11 | <details> 12 | <summary>my-service-staging (examples/kustomize/applications/my-service-staging.yaml)</summary> 13 | <br> 14 | 15 | ```diff 16 | @@ Application modified: my-service-staging (examples/kustomize/applications/my-service-staging.yaml) @@ 17 | apiVersion: apps/v1 18 | kind: Deployment 19 | metadata: 20 | annotations: 21 | argocd.argoproj.io/tracking-id: my-service-staging:apps/Deployment:default/staging-myapp 22 | labels: 23 | app: myapp 24 | name: staging-myapp 25 | namespace: default 26 | spec: 27 | - replicas: 2 28 | + replicas: 6 29 | selector: 30 | matchLabels: 31 | app: myapp 32 | template: 33 | metadata: 34 | labels: 35 | app: myapp 36 | spec: 37 | containers: 38 | - image: dag-andersen/myapp:latest 39 | ``` 40 | 41 | </details> 42 | 43 | _Stats_: 44 | [], [], [], [], [] 45 | -------------------------------------------------------------------------------- /tests/integration-test/branch-4/target/output.md: -------------------------------------------------------------------------------- 1 | ## integration-test/branch-4 2 | 3 | Summary: 4 | ```yaml 5 | Total: 1 files changed 6 | 7 | Added (1): 8 | + folder3 9 | ``` 10 | 11 | <details> 12 | <summary>folder3 (examples/git-generator/app/app-set.yaml)</summary> 13 | <br> 14 | 15 | ```diff 16 | @@ Application added: folder3 (examples/git-generator/app/app-set.yaml) @@ 17 | +apiVersion: apps/v1 18 | +kind: Deployment 19 | +metadata: 20 | + annotations: 21 | + argocd.argoproj.io/tracking-id: folder3:apps/Deployment:/deploy-from-folder-three 22 | + name: deploy-from-folder-three 23 | +spec: 24 | + replicas: 2 25 | + selector: 26 | + matchLabels: 27 | + app: myapp 28 | + template: 29 | + metadata: 30 | + labels: 31 | + app: myapp 32 | + spec: 33 | + containers: 34 | + - image: dag-andersen/myapp:latest 35 | + name: myapp 36 | + ports: 37 | + - containerPort: 80 38 | ``` 39 | 40 | </details> 41 | 42 | _Stats_: 43 | [], [], [], [], [] 44 | -------------------------------------------------------------------------------- /tests/integration-test/branch-5/target-1/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Summary: 4 | ```yaml 5 | Total: 1 files changed 6 | 7 | Modified (1): 8 | ± my-app-watch-pattern-valid-regex 9 | ``` 10 | 11 | <details> 12 | <summary>my-app-watch-pattern-valid-regex (examples/helm/applications/watch-pattern/valid-regex.yaml)</summary> 13 | <br> 14 | 15 | ```diff 16 | @@ Application modified: my-app-watch-pattern-valid-regex (examples/helm/applications/watch-pattern/valid-regex.yaml) @@ 17 | apiVersion: v1 18 | kind: Service 19 | metadata: 20 | annotations: 21 | - argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:/Service:default/super-app-name 22 | + argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:/Service:default/experiment 23 | labels: 24 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 25 | app.kubernetes.io/managed-by: Helm 26 | app.kubernetes.io/name: myApp 27 | app.kubernetes.io/version: 1.16.0 28 | helm.sh/chart: myApp-0.1.0 29 | - name: super-app-name 30 | + name: experiment 31 | namespace: default 32 | spec: 33 | ports: 34 | - name: http 35 | port: 80 36 | protocol: TCP 37 | targetPort: http 38 | selector: 39 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 40 | app.kubernetes.io/name: myApp 41 | type: ClusterIP 42 | 43 | --- 44 | apiVersion: v1 45 | automountServiceAccountToken: true 46 | kind: ServiceAccount 47 | metadata: 48 | annotations: 49 | - argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:/ServiceAccount:default/super-app-name 50 | + argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:/ServiceAccount:default/experiment 51 | labels: 52 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 53 | app.kubernetes.io/managed-by: Helm 54 | app.kubernetes.io/name: myApp 55 | app.kubernetes.io/version: 1.16.0 56 | helm.sh/chart: myApp-0.1.0 57 | - name: super-app-name 58 | + name: experiment 59 | namespace: default 60 | 61 | --- 62 | apiVersion: apps/v1 63 | kind: Deployment 64 | metadata: 65 | annotations: 66 | - argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:apps/Deployment:default/super-app-name 67 | + argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:apps/Deployment:default/experiment 68 | labels: 69 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 70 | app.kubernetes.io/managed-by: Helm 71 | app.kubernetes.io/name: myApp 72 | app.kubernetes.io/version: 1.16.0 73 | helm.sh/chart: myApp-0.1.0 74 | - name: super-app-name 75 | + name: experiment 76 | namespace: default 77 | spec: 78 | replicas: 5 79 | selector: 80 | matchLabels: 81 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 82 | app.kubernetes.io/name: myApp 83 | template: 84 | metadata: 85 | labels: 86 | @@ skipped 15 lines (69 -> 83) @@ 87 | - containerPort: 80 88 | name: http 89 | protocol: TCP 90 | readinessProbe: 91 | httpGet: 92 | path: / 93 | port: http 94 | resources: {} 95 | securityContext: {} 96 | securityContext: {} 97 | - serviceAccountName: super-app-name 98 | + serviceAccountName: experiment 99 | ``` 100 | 101 | </details> 102 | 103 | _Stats_: 104 | [], [], [], [], [] 105 | -------------------------------------------------------------------------------- /tests/integration-test/branch-5/target-2/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Summary: 4 | ```yaml 5 | Total: 1 files changed 6 | 7 | Modified (1): 8 | ± my-app-watch-pattern-valid-regex 9 | ``` 10 | 11 | <details> 12 | <summary>my-app-watch-pattern-valid-regex (examples/helm/applications/watch-pattern/valid-regex.yaml)</summary> 13 | <br> 14 | 15 | ```diff 16 | @@ Application modified: my-app-watch-pattern-valid-regex (examples/helm/applications/watch-pattern/valid-regex.yaml) @@ 17 | apiVersion: v1 18 | kind: Service 19 | metadata: 20 | annotations: 21 | - argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:/Service:default/super-app-name 22 | + argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:/Service:default/experiment 23 | labels: 24 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 25 | app.kubernetes.io/managed-by: Helm 26 | app.kubernetes.io/name: myApp 27 | app.kubernetes.io/version: 1.16.0 28 | helm.sh/chart: myApp-0.1.0 29 | - name: super-app-name 30 | + name: experiment 31 | namespace: default 32 | spec: 33 | ports: 34 | - name: http 35 | port: 80 36 | protocol: TCP 37 | targetPort: http 38 | selector: 39 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 40 | app.kubernetes.io/name: myApp 41 | type: ClusterIP 42 | 43 | --- 44 | apiVersion: v1 45 | automountServiceAccountToken: true 46 | kind: ServiceAccount 47 | metadata: 48 | annotations: 49 | - argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:/ServiceAccount:default/super-app-name 50 | + argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:/ServiceAccount:default/experiment 51 | labels: 52 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 53 | app.kubernetes.io/managed-by: Helm 54 | app.kubernetes.io/name: myApp 55 | app.kubernetes.io/version: 1.16.0 56 | helm.sh/chart: myApp-0.1.0 57 | - name: super-app-name 58 | + name: experiment 59 | namespace: default 60 | 61 | --- 62 | apiVersion: apps/v1 63 | kind: Deployment 64 | metadata: 65 | annotations: 66 | - argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:apps/Deployment:default/super-app-name 67 | + argocd.argoproj.io/tracking-id: my-app-watch-pattern-valid-regex:apps/Deployment:default/experiment 68 | labels: 69 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 70 | app.kubernetes.io/managed-by: Helm 71 | app.kubernetes.io/name: myApp 72 | app.kubernetes.io/version: 1.16.0 73 | helm.sh/chart: myApp-0.1.0 74 | - name: super-app-name 75 | + name: experiment 76 | namespace: default 77 | spec: 78 | replicas: 5 79 | selector: 80 | matchLabels: 81 | app.kubernetes.io/instance: my-app-watch-pattern-valid-regex 82 | app.kubernetes.io/name: myApp 83 | template: 84 | metadata: 85 | labels: 86 | @@ skipped 15 lines (69 -> 83) @@ 87 | - containerPort: 80 88 | name: http 89 | protocol: TCP 90 | readinessProbe: 91 | httpGet: 92 | path: / 93 | port: http 94 | resources: {} 95 | securityContext: {} 96 | securityContext: {} 97 | - serviceAccountName: super-app-name 98 | + serviceAccountName: experiment 99 | ``` 100 | 101 | </details> 102 | 103 | _Stats_: 104 | [], [], [], [], [] 105 | -------------------------------------------------------------------------------- /tests/integration-test/branch-5/target-3/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Found no changed Applications that watched these files: `something/else.yaml` 4 | -------------------------------------------------------------------------------- /tests/integration-test/branch-5/target-4/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Summary: 4 | ```yaml 5 | Total: 1 files changed 6 | 7 | Modified (1): 8 | ± my-app-labels 9 | ``` 10 | 11 | <details> 12 | <summary>my-app-labels (examples/helm/applications/label-selectors/my-app-labels.yaml)</summary> 13 | <br> 14 | 15 | ```diff 16 | @@ Application modified: my-app-labels (examples/helm/applications/label-selectors/my-app-labels.yaml) @@ 17 | apiVersion: v1 18 | kind: Service 19 | metadata: 20 | annotations: 21 | - argocd.argoproj.io/tracking-id: my-app-labels:/Service:default/super-app-name 22 | + argocd.argoproj.io/tracking-id: my-app-labels:/Service:default/experiment 23 | labels: 24 | app.kubernetes.io/instance: my-app-labels 25 | app.kubernetes.io/managed-by: Helm 26 | app.kubernetes.io/name: myApp 27 | app.kubernetes.io/version: 1.16.0 28 | helm.sh/chart: myApp-0.1.0 29 | - name: super-app-name 30 | + name: experiment 31 | namespace: default 32 | spec: 33 | ports: 34 | - name: http 35 | port: 80 36 | protocol: TCP 37 | targetPort: http 38 | selector: 39 | app.kubernetes.io/instance: my-app-labels 40 | app.kubernetes.io/name: myApp 41 | type: ClusterIP 42 | 43 | --- 44 | apiVersion: v1 45 | automountServiceAccountToken: true 46 | kind: ServiceAccount 47 | metadata: 48 | annotations: 49 | - argocd.argoproj.io/tracking-id: my-app-labels:/ServiceAccount:default/super-app-name 50 | + argocd.argoproj.io/tracking-id: my-app-labels:/ServiceAccount:default/experiment 51 | labels: 52 | app.kubernetes.io/instance: my-app-labels 53 | app.kubernetes.io/managed-by: Helm 54 | app.kubernetes.io/name: myApp 55 | app.kubernetes.io/version: 1.16.0 56 | helm.sh/chart: myApp-0.1.0 57 | - name: super-app-name 58 | + name: experiment 59 | namespace: default 60 | 61 | --- 62 | apiVersion: apps/v1 63 | kind: Deployment 64 | metadata: 65 | annotations: 66 | - argocd.argoproj.io/tracking-id: my-app-labels:apps/Deployment:default/super-app-name 67 | + argocd.argoproj.io/tracking-id: my-app-labels:apps/Deployment:default/experiment 68 | labels: 69 | app.kubernetes.io/instance: my-app-labels 70 | app.kubernetes.io/managed-by: Helm 71 | app.kubernetes.io/name: myApp 72 | app.kubernetes.io/version: 1.16.0 73 | helm.sh/chart: myApp-0.1.0 74 | - name: super-app-name 75 | + name: experiment 76 | namespace: default 77 | spec: 78 | replicas: 5 79 | selector: 80 | matchLabels: 81 | app.kubernetes.io/instance: my-app-labels 82 | app.kubernetes.io/name: myApp 83 | template: 84 | metadata: 85 | labels: 86 | @@ skipped 15 lines (69 -> 83) @@ 87 | - containerPort: 80 88 | name: http 89 | protocol: TCP 90 | readinessProbe: 91 | httpGet: 92 | path: / 93 | port: http 94 | resources: {} 95 | securityContext: {} 96 | securityContext: {} 97 | - serviceAccountName: super-app-name 98 | + serviceAccountName: experiment 99 | ``` 100 | 101 | </details> 102 | 103 | _Stats_: 104 | [], [], [], [], [] 105 | -------------------------------------------------------------------------------- /tests/integration-test/branch-5/target-5/output.md: -------------------------------------------------------------------------------- 1 | ## integration-test/branch-5 2 | 3 | Found no changed Applications that matched `team=other-team` 4 | -------------------------------------------------------------------------------- /tests/integration-test/branch-5/target-6/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Summary: 4 | ```yaml 5 | Total: 1 files changed 6 | 7 | Modified (1): 8 | ± my-app-labels 9 | ``` 10 | 11 | <details> 12 | <summary>my-app-labels (examples/helm/applications/label-selectors/my-app-labels.yaml)</summary> 13 | <br> 14 | 15 | ```diff 16 | @@ Application modified: my-app-labels (examples/helm/applications/label-selectors/my-app-labels.yaml) @@ 17 | apiVersion: v1 18 | kind: Service 19 | metadata: 20 | annotations: 21 | - argocd.argoproj.io/tracking-id: my-app-labels:/Service:default/super-app-name 22 | + argocd.argoproj.io/tracking-id: my-app-labels:/Service:default/experiment 23 | labels: 24 | app.kubernetes.io/instance: my-app-labels 25 | app.kubernetes.io/managed-by: Helm 26 | app.kubernetes.io/name: myApp 27 | app.kubernetes.io/version: 1.16.0 28 | helm.sh/chart: myApp-0.1.0 29 | - name: super-app-name 30 | + name: experiment 31 | namespace: default 32 | spec: 33 | ports: 34 | - name: http 35 | port: 80 36 | protocol: TCP 37 | targetPort: http 38 | selector: 39 | app.kubernetes.io/instance: my-app-labels 40 | app.kubernetes.io/name: myApp 41 | type: ClusterIP 42 | 43 | --- 44 | apiVersion: v1 45 | automountServiceAccountToken: true 46 | kind: ServiceAccount 47 | metadata: 48 | annotations: 49 | - argocd.argoproj.io/tracking-id: my-app-labels:/ServiceAccount:default/super-app-name 50 | + argocd.argoproj.io/tracking-id: my-app-labels:/ServiceAccount:default/experiment 51 | labels: 52 | app.kubernetes.io/instance: my-app-labels 53 | app.kubernetes.io/managed-by: Helm 54 | app.kubernetes.io/name: myApp 55 | app.kubernetes.io/version: 1.16.0 56 | helm.sh/chart: myApp-0.1.0 57 | - name: super-app-name 58 | + name: experiment 59 | namespace: default 60 | 61 | --- 62 | apiVersion: apps/v1 63 | kind: Deployment 64 | metadata: 65 | annotations: 66 | - argocd.argoproj.io/tracking-id: my-app-labels:apps/Deployment:default/super-app-name 67 | + argocd.argoproj.io/tracking-id: my-app-labels:apps/Deployment:default/experiment 68 | labels: 69 | app.kubernetes.io/instance: my-app-labels 70 | app.kubernetes.io/managed-by: Helm 71 | app.kubernetes.io/name: myApp 72 | app.kubernetes.io/version: 1.16.0 73 | helm.sh/chart: myApp-0.1.0 74 | - name: super-app-name 75 | + name: experiment 76 | namespace: default 77 | spec: 78 | replicas: 5 79 | selector: 80 | matchLabels: 81 | app.kubernetes.io/instance: my-app-labels 82 | app.kubernetes.io/name: myApp 83 | template: 84 | metadata: 85 | labels: 86 | @@ skipped 15 lines (69 -> 83) @@ 87 | - containerPort: 80 88 | name: http 89 | protocol: TCP 90 | readinessProbe: 91 | httpGet: 92 | path: / 93 | port: http 94 | resources: {} 95 | securityContext: {} 96 | securityContext: {} 97 | - serviceAccountName: super-app-name 98 | + serviceAccountName: experiment 99 | ``` 100 | 101 | </details> 102 | 103 | _Stats_: 104 | [], [], [], [], [] 105 | -------------------------------------------------------------------------------- /tests/integration-test/branch-5/target-7/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Found no Applications 4 | -------------------------------------------------------------------------------- /tests/integration-test/branch-8/target/output.md: -------------------------------------------------------------------------------- 1 | ## Argo CD Diff Preview 2 | 3 | Summary: 4 | ```yaml 5 | Total: 2 files changed 6 | 7 | Added (1): 8 | + folder3 9 | 10 | Modified (1): 11 | ± folder2 12 | ``` 13 | 14 | <details> 15 | <summary>folder2 (examples/git-generator/app/app-set.yaml)</summary> 16 | <br> 17 | 18 | ```diff 19 | @@ Application modified: folder2 (examples/git-generator/app/app-set.yaml) @@ 20 | app: myapp 21 | template: 22 | metadata: 23 | labels: 24 | app: myapp 25 | spec: 26 | containers: 27 | - image: dag-andersen/myapp:latest 28 | name: myapp 29 | ports: 30 | - - containerPort: 80 31 | + - containerPort: 8080 32 | ``` 33 | 34 | </details> 35 | 36 | <details> 37 | <summary>folder3 (examples/git-generator/app/app-set.yaml)</summary> 38 | <br> 39 | 40 | ```diff 41 | @@ Application added: folder3 (examples/git-generator/app/app-set.yaml) @@ 42 | +apiVersion: apps/v1 43 | +kind: Deployment 44 | +metadata: 45 | + annotations: 46 | + argocd.argoproj.io/tracking-id: folder3:apps/Deployment:/deploy-from-folder-three 47 | + name: deploy-from-folder-three 48 | +spec: 49 | + replicas: 2 50 | + selector: 51 | + matchLabels: 52 | + app: myapp 53 | + template: 54 | + metadata: 55 | + labels: 56 | + app: myapp 57 | + spec: 58 | + containers: 59 | + - image: dag-andersen/myapp:latest 60 | + name: myapp 61 | + ports: 62 | + - containerPort: 80 63 | ``` 64 | 65 | </details> 66 | 67 | _Stats_: 68 | [], [], [], [], [] 69 | -------------------------------------------------------------------------------- /tests/kind-config/options.yaml: -------------------------------------------------------------------------------- 1 | kind: Cluster 2 | apiVersion: kind.x-k8s.io/v1alpha4 3 | name: app-1-cluster -------------------------------------------------------------------------------- /tests/makefile: -------------------------------------------------------------------------------- 1 | gitops_repo ?= argocd-diff-preview 2 | github_org ?= dag-andersen 3 | base_branch := main 4 | docker_file := Dockerfile 5 | argocd_namespace := argocd 6 | timeout := 90 7 | line_count := 10 8 | title := Argo CD Diff Preview 9 | 10 | pull-base: 11 | @rm -rf base-branch || true && mkdir -p base-branch 12 | cd base-branch && git clone https://github.com/$(github_org)/$(gitops_repo).git --depth=1 --branch "$(base_branch)" && cp -r $(gitops_repo)/. . && rm -rf .git && echo "*" > .gitignore && rm -rf $(gitops_repo) && cd - 13 | 14 | pull-target: 15 | @rm -rf target-branch || true && mkdir -p target-branch 16 | cd target-branch && git clone https://github.com/$(github_org)/$(gitops_repo).git --depth=1 --branch "$(target_branch)" && cp -r $(gitops_repo)/. . && rm -rf .git && echo "*" > .gitignore && rm -rf $(gitops_repo) && cd - 17 | 18 | clean: 19 | rm -rf base-branch || true 20 | rm -rf target-branch || true 21 | rm -rf output || true 22 | rm -rf temp || true 23 | 24 | run-single-test-go: clean pull-base pull-target 25 | ../bin/argocd-diff-preview \ 26 | --base-branch "$(base_branch)" \ 27 | --target-branch "$(target_branch)" \ 28 | --repo $(github_org)/$(gitops_repo) \ 29 | --argocd-namespace "$(argocd_namespace)" \ 30 | --files-changed="$(files_changed)" \ 31 | --selector="$(selector)" \ 32 | --file-regex="$(file_regex)" \ 33 | --diff-ignore="$(diff_ignore)" \ 34 | --line-count="$(line_count)" \ 35 | --timeout=$(timeout) \ 36 | --kind-options="$(kind_options)" \ 37 | --title="$(title)" 38 | mkdir -p ./$(target_branch)$(suffix)/ 39 | cat ./output/diff.md | sed 's/\[[^][]*\]/[]/g' > ./output/diff-2.md 40 | diff ./output/diff-2.md ./$(target_branch)$(suffix)/output.md 41 | @echo "COMPLETED TEST: $(target_branch)$(suffix) ✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅" 42 | 43 | docker-build: 44 | docker build -f ../$(docker_file) -t argocd-diff-preview .. 45 | 46 | run-single-test-docker: clean pull-base pull-target 47 | docker run --network=host \ 48 | -v /var/run/docker.sock:/var/run/docker.sock \ 49 | -v $(PWD)/base-branch:/base-branch \ 50 | -v $(PWD)/target-branch:/target-branch \ 51 | -v $(PWD)/output:/output \ 52 | -v $(PWD)/secrets:/secrets \ 53 | -v $(PWD)/temp:/temp \ 54 | -v $(PWD)/kind-config:/kind-config \ 55 | -e BASE_BRANCH="$(base_branch)" \ 56 | -e TARGET_BRANCH="$(target_branch)" \ 57 | -e FILE_REGEX="$(file_regex)" \ 58 | -e DIFF_IGNORE="$(diff_ignore)" \ 59 | -e LINE_COUNT="$(line_count)" \ 60 | -e REPO="$(github_org)/$(gitops_repo)" \ 61 | -e ARGOCD_NAMESPACE="$(argocd_namespace)" \ 62 | -e FILES_CHANGED="$(files_changed)" \ 63 | -e SELECTOR="$(selector)" \ 64 | -e TIMEOUT="$(timeout)" \ 65 | -e KIND_OPTIONS="$(kind_options)" \ 66 | -e TITLE="$(title)" \ 67 | argocd-diff-preview 68 | mkdir -p ./$(target_branch)$(suffix)/ 69 | cat ./output/diff.md | sed 's/\[[^][]*\]/[]/g' > ./output/diff-2.md 70 | diff ./output/diff-2.md ./$(target_branch)$(suffix)/output.md 71 | @echo "COMPLETED TEST: $(target_branch)$(suffix) ✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅" 72 | 73 | run-test-all-with-%: 74 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-1/target base_branch=integration-test/branch-1/base diff_ignore="image" line_count=3 kind_options="--name tests" 75 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-2/target base_branch=integration-test/branch-2/base kind_options="--config=./kind-config/options.yaml" 76 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-3/target base_branch=integration-test/branch-3/base kind_options="--config ./kind-config/options.yaml" 77 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-4/target base_branch=integration-test/branch-4/base title="integration-test/branch-4" 78 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-5/target base_branch=integration-test/branch-5/base files_changed="examples/helm/values/filtered.yaml" suffix="-1" 79 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-5/target base_branch=integration-test/branch-5/base files_changed="examples/helm/applications/watch-pattern/valid-regex.yaml" suffix="-2" 80 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-5/target base_branch=integration-test/branch-5/base files_changed="something/else.yaml" suffix="-3" 81 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-5/target base_branch=integration-test/branch-5/base selector="team=my-team" suffix="-4" 82 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-5/target base_branch=integration-test/branch-5/base selector="team=other-team" suffix="-5" title="integration-test/branch-5" 83 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-5/target base_branch=integration-test/branch-5/base file_regex=".*labels\.yaml" suffix="-6" 84 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-5/target base_branch=integration-test/branch-5/base file_regex="this-does-not-exist\.yaml" suffix="-7" 85 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-6/target base_branch=integration-test/branch-6/base 86 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-7/target base_branch=integration-test/branch-7/base files_changed="examples/helm/values/filtered.yaml" 87 | $(MAKE) run-single-test-$* target_branch=integration-test/branch-8/target base_branch=integration-test/branch-8/base files_changed="examples/git-generator/resources/folder2/deployment.yaml,examples/git-generator/resources/folder3/deployment.yaml" 88 | 89 | run-test-all-docker: docker-build 90 | $(MAKE) run-test-all-with-docker 91 | 92 | run-test-all-go: 93 | $(MAKE) run-test-all-with-go --------------------------------------------------------------------------------