├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yaml │ ├── pr-validation.yaml │ ├── release-container-image.yaml │ └── trivy.yaml ├── .gitignore ├── .golangci.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── action-entrypoint.sh ├── action.yaml ├── assets └── kustomization-checks.png ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── pkg ├── command │ ├── command.go │ ├── feature.go │ ├── new.go │ ├── promote.go │ └── status.go ├── config │ ├── config.go │ └── config_test.go ├── git │ ├── azdo.go │ ├── git.go │ ├── github.go │ ├── github_test.go │ ├── provider.go │ ├── provider_test.go │ ├── pull_request.go │ ├── pull_request_test.go │ ├── util.go │ └── util_test.go └── manifest │ ├── image.go │ ├── image_test.go │ ├── kustomization.go │ ├── kustomization_fs.go │ ├── kustomization_test.go │ ├── testdata │ └── duplicate-application │ │ └── apps │ │ ├── base │ │ ├── deployment.yaml │ │ ├── kustomization.yaml │ │ └── service.yaml │ │ └── dev │ │ ├── existing-feature │ │ ├── deployment.yaml │ │ ├── kustomization.yaml │ │ └── service.yaml │ │ └── kustomization.yaml │ ├── util.go │ └── util_test.go └── tests ├── e2e_test.go └── helpers_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: docker 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: github-actions 10 | directory: "/" 11 | schedule: 12 | interval: daily 13 | time: "04:00" 14 | open-pull-requests-limit: 10 15 | - package-ecosystem: gomod 16 | directory: "/" 17 | schedule: 18 | interval: daily 19 | time: "04:00" 20 | open-pull-requests-limit: 10 21 | - package-ecosystem: gitsubmodule 22 | directory: "/" 23 | schedule: 24 | interval: daily 25 | time: "04:00" 26 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yaml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '0 4 * * *' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2.4.0 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/pr-validation.yaml: -------------------------------------------------------------------------------- 1 | name: PR Validation 2 | 3 | on: pull_request 4 | 5 | env: 6 | NAME: "gitops-promotion" 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | env: 12 | GO111MODULE: on 13 | steps: 14 | - name: Clone repo 15 | uses: actions/checkout@v2.4.0 16 | - name: Setup go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: "1.17" 20 | - name: Install libgit2 21 | run: | 22 | cd /tmp 23 | git clone https://github.com/libgit2/libgit2.git 24 | cd libgit2 25 | git checkout maint/v1.3 26 | mkdir build && cd build 27 | cmake .. 28 | sudo cmake --build . --target install 29 | sudo ldconfig 30 | - name: golangci-lint 31 | uses: golangci/golangci-lint-action@v3 32 | 33 | fmt: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Clone repo 37 | uses: actions/checkout@v2.4.0 38 | - name: Setup go 39 | uses: actions/setup-go@v2 40 | with: 41 | go-version: "1.17" 42 | - name: Run fmt 43 | run: | 44 | make fmt 45 | - name: Check if working tree is dirty 46 | run: | 47 | if [[ $(git status --porcelain) ]]; then 48 | git diff 49 | echo 'run make fmt and commit changes' 50 | exit 1 51 | fi 52 | 53 | # semgrep: 54 | # runs-on: ubuntu-latest 55 | # steps: 56 | # - uses: actions/checkout@v1 57 | # - uses: returntocorp/semgrep-action@v1 58 | # env: 59 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | # with: 61 | # publishToken: ${{ secrets.SEMGREP_APP_TOKEN }} 62 | # publishDeployment: 284 63 | 64 | #coverage: 65 | # runs-on: ubuntu-latest 66 | # steps: 67 | # - name: Clone repo 68 | # uses: actions/checkout@v2.4.0 69 | # - name: Setup go 70 | # uses: actions/setup-go@v2 71 | # with: 72 | # go-version: "1.17" 73 | # - name: Install libgit2 74 | # run: | 75 | # cd /tmp 76 | # git clone https://github.com/libgit2/libgit2.git 77 | # cd libgit2 78 | # git checkout maint/v1.3 79 | # mkdir build && cd build 80 | # cmake .. 81 | # sudo cmake --build . --target install 82 | # sudo ldconfig 83 | # - name: coverage 84 | # env: 85 | # AZDO_PAT: ${{ secrets.AZDO_PAT }} 86 | # AZDO_URL: ${{ secrets.AZDO_URL }} 87 | # GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 88 | # GITHUB_URL: ${{ secrets.GH_URL }} 89 | # run: | 90 | # make cover 91 | # - name: Send coverage to coverall 92 | # uses: shogo82148/actions-goveralls@v1 93 | # with: 94 | # path-to-profile: tmp/coverage.out 95 | # ignore: main.go 96 | 97 | build-container: 98 | runs-on: ubuntu-latest 99 | steps: 100 | - name: Clone repo 101 | uses: actions/checkout@v2.4.0 102 | - name: Prepare 103 | id: prep 104 | run: | 105 | VERSION=sha-${GITHUB_SHA::8} 106 | if [[ $GITHUB_REF == refs/tags/* ]]; then 107 | VERSION=${GITHUB_REF/refs\/tags\//} 108 | fi 109 | echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT 110 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 111 | - uses: brpaz/hadolint-action@v1.5.0 112 | with: 113 | dockerfile: Dockerfile 114 | - name: Cache container layers 115 | uses: actions/cache@v2.1.7 116 | with: 117 | path: /tmp/.buildx-cache 118 | key: ${{ runner.os }}-buildx-${{ github.sha }} 119 | restore-keys: | 120 | ${{ runner.os }}-buildx- 121 | - name: Set up QEMU 122 | uses: docker/setup-qemu-action@v1 123 | - name: Set up Docker Buildx 124 | uses: docker/setup-buildx-action@v1.6.0 125 | - name: Build and load (current arch) 126 | run: | 127 | docker buildx build --load -t ${{ env.NAME }}:${{ steps.prep.outputs.VERSION }} . 128 | - name: Setup go 129 | uses: actions/setup-go@v2 130 | with: 131 | go-version: "1.17" 132 | - name: Install libgit2 133 | run: | 134 | cd /tmp 135 | git clone https://github.com/libgit2/libgit2.git 136 | cd libgit2 137 | git checkout maint/v1.3 138 | mkdir build && cd build 139 | cmake .. 140 | sudo cmake --build . --target install 141 | sudo ldconfig 142 | #- name: Run integration tests 143 | # env: 144 | # AZDO_PAT: ${{ secrets.AZDO_PAT }} 145 | # AZDO_URL: ${{ secrets.AZDO_URL }} 146 | # GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 147 | # GITHUB_URL: ${{ secrets.GH_URL }} 148 | # run: | 149 | # make verify GITOPS_PROMOTION_IMAGE=${{ env.NAME }}:${{ steps.prep.outputs.VERSION }} 150 | - name: Run Trivy vulnerability scanner 151 | uses: aquasecurity/trivy-action@master 152 | with: 153 | image-ref: ${{ env.NAME }}:${{ steps.prep.outputs.VERSION }} 154 | format: "table" 155 | exit-code: "1" 156 | ignore-unfixed: true 157 | severity: "CRITICAL,HIGH" 158 | -------------------------------------------------------------------------------- /.github/workflows/release-container-image.yaml: -------------------------------------------------------------------------------- 1 | name: Release Container Image 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | env: 9 | NAME: "gitops-promotion" 10 | 11 | jobs: 12 | push-container: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Clone repo 16 | uses: actions/checkout@v2.4.0 17 | - name: Prepare 18 | id: prep 19 | run: | 20 | VERSION=sha-${GITHUB_SHA::8} 21 | if [[ $GITHUB_REF == refs/tags/* ]]; then 22 | VERSION=${GITHUB_REF/refs\/tags\//} 23 | fi 24 | echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT 25 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 26 | - name: Get GitHub Tag 27 | id: get_tag 28 | run: | 29 | echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 30 | - name: Cache container layers 31 | uses: actions/cache@v2.1.7 32 | with: 33 | path: /tmp/.buildx-cache 34 | key: ${{ runner.os }}-buildx-${{ github.sha }} 35 | restore-keys: | 36 | ${{ runner.os }}-buildx- 37 | - name: Set up QEMU 38 | uses: docker/setup-qemu-action@v1 39 | - name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v1.6.0 41 | - name: Login to GitHub Container Registry 42 | uses: docker/login-action@v1 43 | with: 44 | registry: ghcr.io 45 | username: ${{ github.repository_owner }} 46 | password: ${{ secrets.GITHUB_TOKEN }} 47 | - name: Build and push container (multi arch) 48 | uses: docker/build-push-action@v2 49 | with: 50 | push: true 51 | context: . 52 | file: ./Dockerfile 53 | platforms: linux/amd64,linux/arm64 54 | cache-from: type=local,src=/tmp/.buildx-cache 55 | cache-to: type=local,dest=/tmp/.buildx-cache 56 | tags: ghcr.io/xenitab/${{ env.NAME }}:${{ steps.get_tag.outputs.tag }} 57 | build-args: | 58 | VERSION=${{ steps.prep.outputs.VERSION }} 59 | REVISION=${{ github.sha }} 60 | CREATED=${{ steps.prep.outputs.BUILD_DATE }} 61 | labels: | 62 | org.opencontainers.image.title=${{ github.event.repository.name }} 63 | org.opencontainers.image.description=${{ github.event.repository.description }} 64 | org.opencontainers.image.url=${{ github.event.repository.html_url }} 65 | org.opencontainers.image.revision=${{ github.sha }} 66 | org.opencontainers.image.version=${{ steps.prep.outputs.VERSION }} 67 | org.opencontainers.image.created=${{ steps.prep.outputs.BUILD_DATE }} 68 | - name: Check images 69 | run: | 70 | docker buildx imagetools inspect ghcr.io/xenitab/${{ env.NAME }}:${{ steps.get_tag.outputs.tag }} 71 | docker pull ghcr.io/xenitab/${{ env.NAME }}:${{ steps.get_tag.outputs.tag }} 72 | -------------------------------------------------------------------------------- /.github/workflows/trivy.yaml: -------------------------------------------------------------------------------- 1 | name: Trivy 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | schedule: 9 | - cron: "0 4 * * *" 10 | 11 | env: 12 | NAME: "gitops-promotion" 13 | 14 | jobs: 15 | trivy: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Clone repo 19 | uses: actions/checkout@v2.4.0 20 | - name: Prepare 21 | id: prep 22 | run: | 23 | VERSION=sha-${GITHUB_SHA::8} 24 | if [[ $GITHUB_REF == refs/tags/* ]]; then 25 | VERSION=${GITHUB_REF/refs\/tags\//} 26 | fi 27 | echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT 28 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 29 | - uses: brpaz/hadolint-action@v1.5.0 30 | with: 31 | dockerfile: Dockerfile 32 | - name: Cache container layers 33 | uses: actions/cache@v2.1.7 34 | with: 35 | path: /tmp/.buildx-cache 36 | key: ${{ runner.os }}-buildx-${{ github.sha }} 37 | restore-keys: | 38 | ${{ runner.os }}-buildx- 39 | - name: Set up QEMU 40 | uses: docker/setup-qemu-action@v1 41 | - name: Set up Docker Buildx 42 | uses: docker/setup-buildx-action@v1.6.0 43 | - name: Build container (multi arch) 44 | uses: docker/build-push-action@v2 45 | with: 46 | context: . 47 | file: ./Dockerfile 48 | platforms: linux/amd64 49 | cache-from: type=local,src=/tmp/.buildx-cache 50 | cache-to: type=local,dest=/tmp/.buildx-cache 51 | tags: ${{ env.NAME }}:${{ steps.prep.outputs.VERSION }} 52 | load: true 53 | labels: | 54 | org.opencontainers.image.title=${{ github.event.repository.name }} 55 | org.opencontainers.image.description=${{ github.event.repository.description }} 56 | org.opencontainers.image.url=${{ github.event.repository.html_url }} 57 | org.opencontainers.image.revision=${{ github.sha }} 58 | org.opencontainers.image.version=${{ steps.prep.outputs.VERSION }} 59 | org.opencontainers.image.created=${{ steps.prep.outputs.BUILD_DATE }} 60 | - name: Run Trivy vulnerability scanner 61 | uses: aquasecurity/trivy-action@master 62 | with: 63 | image-ref: ${{ env.NAME }}:${{ steps.prep.outputs.VERSION }} 64 | format: "template" 65 | template: "@/contrib/sarif.tpl" 66 | output: "trivy-results.sarif" 67 | - name: Upload Trivy scan results to GitHub Security tab 68 | uses: github/codeql-action/upload-sarif@v1 69 | with: 70 | sarif_file: "trivy-results.sarif" 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/go 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | ### Go Patch ### 23 | /vendor/ 24 | /Godeps/ 25 | 26 | # End of https://www.toptal.com/developers/gitignore/api/go 27 | 28 | bin 29 | tmp 30 | __debug_bin 31 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 4m 3 | 4 | linters: 5 | disable-all: false 6 | enable: 7 | - gocyclo 8 | - misspell 9 | - nilerr 10 | - unparam 11 | - gosec 12 | - unused 13 | - govet 14 | - gosimple 15 | - errorlint 16 | - errcheck 17 | - dupl 18 | - dogsled 19 | - cyclop 20 | - exhaustive 21 | - funlen 22 | - gocognit 23 | - nestif 24 | - goconst 25 | - gocritic 26 | - godot 27 | - gofmt 28 | - revive 29 | - lll 30 | - makezero 31 | - nakedret 32 | - prealloc 33 | - nolintlint 34 | - staticcheck 35 | - thelper 36 | - whitespace 37 | 38 | linters-settings: 39 | gocyclo: 40 | min-complexity: 20 41 | 42 | misspell: 43 | locale: US 44 | 45 | unused: 46 | go: "1.16" 47 | 48 | unparam: 49 | check-exported: true 50 | 51 | govet: 52 | check-shadowing: false 53 | 54 | gosimple: 55 | go: "1.16" 56 | checks: [ "all" ] 57 | 58 | errorlint: 59 | errorf: true 60 | asserts: true 61 | comparison: true 62 | 63 | errcheck: 64 | check-type-assertions: true 65 | check-blank: true 66 | 67 | dupl: 68 | threshold: 100 69 | 70 | dogsled: 71 | max-blank-identifiers: 2 72 | 73 | cyclop: 74 | max-complexity: 15 75 | package-average: 0.0 76 | skip-tests: true 77 | 78 | exhaustive: 79 | check-generated: false 80 | default-signifies-exhaustive: false 81 | 82 | funlen: 83 | lines: 80 84 | statements: 50 85 | 86 | gocognit: 87 | min-complexity: 15 88 | 89 | nestif: 90 | min-complexity: 5 91 | 92 | goconst: 93 | min-len: 3 94 | min-occurrences: 3 95 | 96 | gocritic: 97 | enabled-checks: 98 | - unnamedresult 99 | - truncatecmp 100 | - ruleguard 101 | - nestingreduce 102 | enabled-tags: 103 | - performance 104 | disabled-tags: 105 | - experimental 106 | settings: 107 | captLocal: 108 | paramsOnly: true 109 | elseif: 110 | skipBalanced: true 111 | hugeParam: 112 | sizeThreshold: 80 113 | nestingReduce: 114 | bodyWidth: 5 115 | rangeExprCopy: 116 | sizeThreshold: 512 117 | skipTestFuncs: true 118 | rangeValCopy: 119 | sizeThreshold: 32 120 | skipTestFuncs: true 121 | ruleguard: 122 | rules: '' 123 | truncateCmp: 124 | skipArchDependent: true 125 | underef: 126 | skipRecvDeref: true 127 | unnamedResult: 128 | checkExported: true 129 | 130 | godot: 131 | scope: declarations 132 | capital: false 133 | 134 | gofmt: 135 | simplify: true 136 | 137 | revive: 138 | ignore-generated-header: true 139 | severity: warning 140 | 141 | lll: 142 | line-length: 140 143 | tab-width: 1 144 | 145 | makezero: 146 | always: false 147 | 148 | nakedret: 149 | max-func-lines: 30 150 | 151 | prealloc: 152 | simple: true 153 | range-loops: true 154 | for-loops: false 155 | 156 | nolintlint: 157 | allow-unused: false 158 | allow-leading-space: true 159 | allow-no-explanation: [] 160 | require-explanation: true 161 | require-specific: true 162 | 163 | staticcheck: 164 | go: "1.16" 165 | checks: [ "all" ] 166 | 167 | thelper: 168 | test: 169 | first: true 170 | name: true 171 | begin: true 172 | benchmark: 173 | first: true 174 | name: true 175 | begin: true 176 | tb: 177 | first: true 178 | name: true 179 | begin: true 180 | 181 | whitespace: 182 | multi-if: false 183 | multi-func: false 184 | 185 | issues: 186 | exclude-rules: 187 | - path: _test\.go 188 | linters: 189 | - gocyclo 190 | - funlen 191 | - gocognit 192 | - unparam 193 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17-alpine3.15 as builder 2 | RUN apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.15/main/ gcc=~10.3 pkgconfig=~1.7 musl-dev=~1.2 libgit2-dev=~1.3 binutils-gold=~2.37 3 | WORKDIR /workspace 4 | COPY go.mod go.mod 5 | COPY go.sum go.sum 6 | RUN go mod download 7 | COPY main.go main.go 8 | COPY pkg/ pkg/ 9 | RUN CGO_ENABLED=1 go build -o gitops-promotion main.go 10 | 11 | FROM alpine:3.15.0 12 | LABEL org.opencontainers.image.source="https://github.com/XenitAB/gitops-promotion" 13 | # hadolint ignore=DL3017,DL3018 14 | RUN apk upgrade --no-cache && \ 15 | apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.15/main/ ca-certificates tini=~0.19 libgit2=~1.3 16 | COPY --from=builder /workspace/gitops-promotion /usr/local/bin/ 17 | COPY ./action-entrypoint.sh /usr/local/bin/ 18 | WORKDIR /workspace 19 | ENTRYPOINT [ "/sbin/tini", "--", "gitops-promotion" ] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Xenit AB 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAG = dev 2 | GITOPS_PROMOTION_IMAGE ?= ghcr.io/xenitab/gitops-promotion:$(TAG) 3 | TEST_ENV_FILE = tmp/test_env 4 | 5 | ifneq (,$(wildcard $(TEST_ENV_FILE))) 6 | include $(TEST_ENV_FILE) 7 | export 8 | endif 9 | 10 | .SILENT: lint 11 | .PHONY: lint 12 | lint: 13 | golangci-lint run ./... 14 | 15 | .SILENT: fmt 16 | .PHONY: fmt 17 | fmt: 18 | go fmt ./... 19 | 20 | .SILENT: vet 21 | .PHONY: vet 22 | vet: 23 | go vet ./... 24 | 25 | .SILENT: test 26 | .PHONY: test 27 | test: fmt vet 28 | go test -timeout 2m ./... -cover 29 | 30 | cover: 31 | mkdir -p tmp 32 | go test -timeout 5m -coverpkg=./pkg/... -coverprofile=tmp/coverage.out ./pkg/... 33 | go tool cover -html=tmp/coverage.out 34 | 35 | docker-build: 36 | docker build -t ${GITOPS_PROMOTION_IMAGE} . 37 | 38 | verify: 39 | go test -timeout 2m -v ./tests/... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitOps Promotion 2 | 3 | ## Overview 4 | 5 | gitops-promotion is tool to do automatic promotion with a GitOps workflow. It is ideally suited for use with [Kubernetes](https://kubernetes.io) manifests and a controller such as [Flux](https://fluxcd.io). 6 | 7 | gitops-promotion interacts with a Git provider to do automatic propagation of container images across a succession of environments. Supported Git providers: 8 | 9 | - GitHub 10 | - Azure Devops 11 | 12 | ## The workflow 13 | 14 | gitops-promotion workflow assumes a separation of one or more "app" repositories, which results in container images, and the repository (or repositories) that hold manifests that describe how those containers are deployed. We refer to this repository as the "GitOps" repository. Assuming a typical dev/qa/production succession of environments, gitops-promotion is meant to support a workflow that looks like this: 15 | 16 | 1. A pipeline in `webui` app repository builds, tests and delivers an image to the container registry. 17 | 1. The new container image triggers a new promotion (`gitops-promotion new`) in the GitOps repository. It creates a new branch `promote/webshop-webui` an auto-merging pull request for the "dev" env. It updates the manifest of the app with the new image. 18 | 1. The auto-merge triggers the promote pipeline (`gitops-promote promote`) in the GitOps repository. This pipeline goes through the same steps as "new" above except that it targets the next environment, in this case the "qa" environment. 19 | 1. The promotion pull request for the "qa" env triggers the "status" pipeline (`gitops-promotions status`). This pipeline checks the status of the "dev" pull request (including any reconciliation status added by Flux) and reports that status as its own. 20 | 1. Assuming the "dev" pull request status is green, the "qa" pull request is merged. 21 | 1. Steps 4. and 5. are repeated for the "production" environment, but without auto-merge, so that they can be applied at an opportune time. 22 | 23 | Conceptually, this means that: 24 | 25 | - all new container images are applied to the "dev" environment 26 | - all new container images that are successfully applied will be propagated to the "qa" environment 27 | - pull requests for applying changes to the production environment are automatically created and can be merged by testers or product owners once they have been validated in the "qa" environment. 28 | 29 | See the provider-specific sections below for details about how to implement these pipelines. 30 | 31 | ## The commands 32 | 33 | ### gitops-promotion new 34 | 35 | ```shell 36 | $ gitops-promotion new --help 37 | Usage of new: 38 | --app string 39 | Name of the application 40 | --group string 41 | Main application group 42 | --provider string 43 | git provider to use (default "azdo") 44 | --tag string 45 | Application version/tag to set 46 | --token string 47 | Access token (PAT) to git provider 48 | ``` 49 | 50 | The `new` command goes through this process: 51 | 52 | 1. creates a new branch `promote/-` (or `promote/--` if `per-env` is set; resets it if it already exists), 53 | 1. updates the image tag for the app manifest in the first environment listed in the config file to the newly released container image (see below for more info how this works), 54 | 1. creates an auto-merging pull request, 55 | 1. Assuming the pull request has no failing checks, it is automatically merged into main, where a service such as Flux can apply it to the first environment. 56 | 57 | ### gitops-promotion promote 58 | 59 | ```shell 60 | $ gitops-promotion promote --help 61 | Usage of promote: 62 | --provider string 63 | git provider to use (default "azdo") 64 | --token string 65 | Access token (PAT) to git provider 66 | ``` 67 | 68 | The `promote` command is meant to be used in a pipeline that reacts to merge operations to the main branch that resulted from `new` or `promote` command. It looks up the pull request and uses the information contained therein to create a new pull request, following the process outlined under the `new` command. 69 | 70 | ### gitops-promotion status 71 | 72 | ```shell 73 | $ gitops-promotion status --help 74 | Usage of status: 75 | --provider string 76 | git provider to use (default "azdo") 77 | --token string 78 | Access token (PAT) to git provider 79 | ``` 80 | 81 | The `status` command requests statuses on the merge commit that resulted from the previous' environment's pull request. It looks for a status check with context `*/-`. This matches the metadata name of a [Kustomization](https://fluxcd.io/docs/components/kustomize/kustomization/) resource as reported by the Flux Notification controller (in this case group is "apps"): 82 | 83 | ![Kustomization checks](./assets/kustomization-checks.png) 84 | 85 | If there is no matching status, it then looks on the head commit of "main" branch. If another commit is added to main before Flux has time to consider the merge commit, the merge commit status will never be set, but a relevant status will eventually be set on "main" branch. 86 | 87 | The `status` command keeps looking for statuses for some time. If there is no status after some minutes, the `status` command fails, resulting in a failed check on the pull request, blocking any automatic merging. 88 | 89 | ### gitops-promotion feature 90 | 91 | ```shell 92 | $ gitops-promotion feature --help 93 | Usage of feature: 94 | --app string 95 | Name of the application 96 | --group string 97 | Main application group 98 | --provider string 99 | git provider to use (default "azdo") 100 | --tag string 101 | Application version/tag to set 102 | --feature strng 103 | Application feature 104 | --token string 105 | Access token (PAT) to git provider 106 | ``` 107 | 108 | The `feature` command is used to create temporary deployments of applications. It can either overwrite an existing applications image tag, or it can create a new copy of all of the applications manifests. This behavior depends on if `featureOverwrite` is enabled or not. Either way a feature will never be promoted. 109 | 110 | ## The GitOps repository 111 | 112 | gitops-promotion assumes a repository with a layout like this (excluding CI pipeline definitions). In Flux, this is referred to as a [Monorepo](https://fluxcd.io/docs/guids/repository-structure/#monorepo) layout: 113 | 114 | ```.shell 115 | |-- gitops-promotion.yaml 116 | |-- 117 | | |-- 118 | | | |-- ... <-- your Kubernetes YAML here 119 | | | 120 | | 121 | | |-- ... 122 | ``` 123 | 124 | Assuming we are serving a webshop from Kubernetes with three environments, the file structure may looks something like this: 125 | 126 | ```.shell 127 | |-- gitops-promotion.yaml 128 | |-- webshop 129 | | |-- dev 130 | | | |-- cart.yaml 131 | | | |-- webui.yaml 132 | | |-- qa 133 | | | |-- cart.yaml 134 | | | |-- webui.yaml 135 | | | production 136 | | | |-- cart.yaml 137 | | | |-- webui.yaml 138 | ``` 139 | 140 | See below for details about the `gitops-promotion.yaml` file. 141 | 142 | gitops-promotion uses [Flux image-automation-controller](https://github.com/fluxcd/image-automation-controller) to update the Container image tag for containers in your manifests. You annotate the image reference in your manifest (or in your Kustomization `image` override) like so, where `` and `` are arbitrary names that you use to group and name the services gitops-promotion is working with. For more information, see [Configure image updates](https://fluxcd.io/docs/guides/image-update/#configure-image-updates) in the Flux documentation. 143 | 144 | ```yaml 145 | image: some/image:latest # {"$imagepolicy": "::tag"} 146 | ``` 147 | 148 | For example, you may have a very simple manifest like this: 149 | 150 | ```yaml 151 | apiVersion: apps/v1 152 | kind: Pod 153 | metadata: 154 | name: webui 155 | spec: 156 | containers: 157 | - name: webui 158 | image: ghcr.io/my-org/webui:1234567 # {"$imagepolicy": "webshop:ui:tag"} 159 | ``` 160 | 161 | When you have pushed a new image to your container registry, the pipeline runs the following command to start the promotion of your latest image across the configured environments: 162 | 163 | ```shell 164 | gitops-promotion new --provider azdo --token s3cr3t --group webshop --app webui --tag 26f50d84db02 165 | ``` 166 | 167 | This will instruct gitops-promotion to look up the `$imagepolicy` entry `webshop:webui:tag` and update the container tag to refer to the tag `26f50d84db02` with the expectation that a GitOps controller such as Flux will react to this change and update the corresponding environment accordingly. 168 | 169 | ## Configuration 170 | 171 | The `gitops-promotion.yaml` lists environment names and whether they allow automatic promotion. A typical config file looks like this. gitops-promotion will promote your change across environments in this order. 172 | 173 | ```.yaml 174 | prflow: per-app 175 | environments: 176 | - name: dev 177 | auto: true 178 | - name: qa 179 | auto: true 180 | - name: prod 181 | auto: false 182 | groups: 183 | apps: 184 | applications: 185 | podinfo: 186 | featureOverwrite: false 187 | featureLabelSelector: 188 | app: podinfo 189 | ``` 190 | 191 | | property | usage | 192 | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | 193 | | prflow | `per-app` means later changes will "reset" the single PR for that app, while `per-env` will upsert a PR that app's PR for a particular environment | 194 | | environments[].auto | Whether pull requests for this environment auto-merge or not | 195 | | environments[].name | The name for this environment. Must correspond to a directory present in all groups | 196 | 197 | ## Using with Azure Devops 198 | 199 | Support for Azure DevOps is mature, but alas the documentation is not. TBD. 200 | 201 | ## Using with Github 202 | 203 | gitops-promotion has full support for GitHub. 204 | 205 | ### Required repository configuration 206 | 207 | gitops-promotion makes use of [PR auto-merge](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request). This requires specific configuration: 208 | 209 | 1. In repository settings, turn on "Allow auto-merge" 210 | 1. Set up a branch protection rule for your "main" branch: 211 | 212 | - "Require a pull request before merging" 213 | - "Require status checks to pass" 214 | - Add the workflow for the `status` commend to "Status checks that are required". In the example below, you would enter "prev-env-status". Bizarrely, the UI will not allow you to enter the name, so you may have to trigger a run once so you can use the search interface. 215 | 216 | You can verify that your settings are correct by manually creating a pull request and verify that the button says "Enable auto-merge". 217 | 218 | ### Configuring your workflow 219 | 220 | gitops-promotion is available as a Github Action. 221 | 222 | Depending on which container registry you are using, you may be able to set up triggers that activates your gitops-promotion workflow. If this is not the case, you can use GitHub [repository_dispatch](https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#repository_dispatch) events. These allow GitHub actions on one repository to notify another repository. Use the excellent [repository-dispatch GitHub Action](https://github.com/marketplace/actions/repository-dispatch) for readable YAML. You would add a step at the end of your container-building workflow that looks something like this: 223 | 224 | ```yaml 225 | - name: Notify gitops-promotion workflow 226 | uses: peter-evans/repository-dispatch@v1 227 | with: 228 | token: ${{ secrets.GITOPS_REPO_TOKEN }} 229 | repository: my-org/my-gitops 230 | event-type: image-push 231 | client-payload: | 232 | { 233 | "group": "apps", 234 | "app": "my-app", 235 | "tag": "${{ github.sha }}" 236 | } 237 | ``` 238 | 239 | The `repository` parameter holds the repository where you want to run `gitops-promotion`. The normal `${{ secrets.GITHUB_TOKEN }}` only has access to the local repository running in which the workflow is running, so we need to set up and pass an access token (GITOPS_REPO_TOKEN) that has access to that repository. 240 | 241 | Here is a complete example GitHub workflow for pushing a containerized app to GitHub Container Registry: 242 | 243 | ```yaml 244 | on: 245 | push: 246 | branches: 247 | - main 248 | 249 | jobs: 250 | build-app: 251 | runs-on: ubuntu-latest 252 | steps: 253 | - name: Checkout 254 | uses: actions/checkout@v2 255 | - name: Set up Docker Buildx 256 | uses: docker/setup-buildx-action@v1 257 | - name: Login to GitHub Container Registry 258 | uses: docker/login-action@v1 259 | with: 260 | registry: ghcr.io 261 | username: ${{ github.repository_owner }} 262 | password: ${{ secrets.GITHUB_TOKEN }} 263 | - name: Build and push 264 | uses: docker/build-push-action@v2 265 | with: 266 | push: true 267 | tags: ghcr.io/${{ github.repository_owner }}/my-app:${{ github.sha }} 268 | - name: Notify gitops-promotion workflow 269 | uses: peter-evans/repository-dispatch@v1 270 | with: 271 | token: ${{ secrets.GITOPS_REPO_TOKEN }} 272 | repository: ${{ github.repository_owner }}/my-gitops 273 | event-type: image-push 274 | client-payload: | 275 | { 276 | "group": "apps", 277 | "app": "my-app", 278 | "tag": "${{ github.sha }}" 279 | } 280 | ``` 281 | 282 | In your gitops repository, you can react to `repository-dispatch` events and trigger promotion: 283 | 284 | ```yaml 285 | on: 286 | repository_dispatch: 287 | types: 288 | - image-push 289 | jobs: 290 | new-pr: 291 | runs-on: ubuntu-latest 292 | steps: 293 | - name: Checkout 294 | uses: actions/checkout@v2 295 | with: 296 | # gitops-promotion currently needs access to history 297 | fetch-depth: 0 298 | - uses: xenitab/gitops-promotion@v0.1.0 299 | with: 300 | token: ${{ secrets.GITHUB_TOKEN }} 301 | action: new 302 | group: ${{ github.event.client_payload.group }} 303 | app: ${{ github.event.client_payload.app }} 304 | tag: ${{ github.event.client_payload.tag }} 305 | ``` 306 | 307 | This simple example will start the promotion of `my-app` onto the first environment defined in the `gitops-promotion.yaml` file. In order to promote `my-app` to further environments, set up a separate workflow that reacts to merges from previous promotions, like so: 308 | 309 | ```yaml 310 | on: 311 | push: 312 | branches: 313 | - main 314 | jobs: 315 | promote-app: 316 | runs-on: ubuntu-latest 317 | steps: 318 | - name: Checkout 319 | uses: actions/checkout@v2 320 | with: 321 | # gitops-promotion currently needs access to history 322 | fetch-depth: 0 323 | - uses: xenitab/gitops-promotion@v0.1.0 324 | with: 325 | token: ${{ secrets.GITHUB_TOKEN }} 326 | action: promote 327 | ``` 328 | 329 | In order to block automatic promotion, you can add a status workflow: 330 | 331 | ```yaml 332 | on: 333 | pull_request: 334 | branches: 335 | - main 336 | jobs: 337 | prev-env-status: 338 | runs-on: ubuntu-latest 339 | steps: 340 | - name: Checkout 341 | uses: actions/checkout@v2 342 | with: 343 | # gitops-promotion currently needs access to history 344 | fetch-depth: 0 345 | - uses: xenitab/gitops-promotion@v0.1.0 346 | with: 347 | token: ${{ secrets.GITHUB_TOKEN }} 348 | action: status 349 | ``` 350 | 351 | ### GitHub App authentication 352 | 353 | For simplicity, the above example uses a Personal Access Token for authentication. However, in a production setup you probably want to use a [GitHub App](https://docs.github.com/en/developers/apps/building-github-apps/creating-a-github-app). Once you have set up a GitHub app, you can use the [tibdex/github-app-token](https://github.com/tibdex/github-app-token) action to generate a token for the app to access the repository. (In the case of the `build-app` job above, you also want to add `repository: ${{ github.repository_owner }}/my-gitops` since the token should be valid for the repository we dispatch to.) 354 | 355 | ```yaml 356 | # ... 357 | - name: Generate GitHub App token 358 | uses: tibdex/github-app-token@v1 359 | id: generate_token 360 | with: 361 | app_id: ${{ secrets.MY_GITHUB_APP_ID }} 362 | private_key: ${{ secrets.MY_GITHUB_APP_PRIVATE_KEY }} 363 | # It defaults to current repo, so with peter-evans/repository-dispatch you need to specify repo 364 | # repository: ${{ github.repository_owner }}/my-gitops 365 | - uses: xenitab/gitops-promotion@v0.1.0 366 | with: 367 | token: ${{ steps.generate_token.outputs.token }} 368 | # ... 369 | ``` 370 | 371 | Please note that you will need to make this a required check for merging into main, so it is important that it runs on all pull requests against "main" or your manual pull requests will not be mergeable. 372 | 373 | ## Troubleshooting 374 | 375 | **GitHub PR creation says "could not set auto-merge on PR"**: Your repository is not properly configured to allow pull request auto-merge. Please see the configuration section above for information on how to do this. 376 | 377 | ## Building 378 | 379 | You will need pkg-config and libgit2, please install it from your package manager. 380 | 381 | ## Testing the GitHub provider 382 | 383 | The test suite for the GitHub provider requires access to an actual GitHub repository. In order to run these tests, create an empty repository and set up an access key and invoke the tests like so: 384 | 385 | env GITHUB_URL='' GITHUB_TOKEN='' go test ./... 386 | 387 | The GitHub Action CI runs the tests against [https://github.com/gitops-promotion/gitops-promotion-testing](https://github.com/gitops-promotion/gitops-promotion-testing). 388 | 389 | In order to test interactions manually, you may want to trigger a new promotion. Assuming you are using the example above based on repository-dispatch, the following command will inject a new event: 390 | 391 | ```shell 392 | curl -X POST \ 393 | -H "Authorization: token " \ 394 | -H "Accept: application/vnd.github.v3+json" \ 395 | -d '{"event_type": "image-push", "client_payload": {"group": "apps", "app": "my-app", "tag": "123456"}}' \ 396 | https://api.github.com/repos///dispatches 397 | ``` 398 | 399 | In order to emulate a status update from Flux, use the following command: 400 | 401 | ```shell 402 | curl -X POST \ 403 | -H "Authorization: token " \ 404 | -H "Accept: application/vnd.github.v3+json" \ 405 | -d '{"state": "success", "context": "kustomization/apps-qa", "description": "reconciliation succeeded"}' \ 406 | https://api.github.com/repos///commits//statuses 407 | ``` 408 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Vulnerability Disclosure Policy 2 | 3 | See [xenit.se/vdp](https://xenit.se/vdp/) for more information. 4 | -------------------------------------------------------------------------------- /action-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | case $ACTION in 6 | new) 7 | /usr/local/bin/gitops-promotion new \ 8 | --provider github \ 9 | --sourcedir "$GITHUB_WORKSPACE" \ 10 | --token "$TOKEN" \ 11 | --group "$GROUP" \ 12 | --app "$APP" \ 13 | --tag "$TAG" 14 | ;; 15 | feature) 16 | /usr/local/bin/gitops-promotion feature \ 17 | --provider github \ 18 | --sourcedir "$GITHUB_WORKSPACE" \ 19 | --token "$TOKEN" \ 20 | --group "$GROUP" \ 21 | --app "$APP" \ 22 | --tag "$TAG" \ 23 | --feature "$FEATURE" 24 | ;; 25 | feature-stale) 26 | /usr/local/bin/gitops-promotion feature-stale \ 27 | --provider github \ 28 | --sourcedir "$GITHUB_WORKSPACE" \ 29 | --token "$TOKEN" \ 30 | ;; 31 | promote) 32 | /usr/local/bin/gitops-promotion promote \ 33 | --provider github \ 34 | --sourcedir "$GITHUB_WORKSPACE" \ 35 | --token "$TOKEN" 36 | ;; 37 | status) 38 | /usr/local/bin/gitops-promotion status \ 39 | --provider github \ 40 | --sourcedir "$GITHUB_WORKSPACE" \ 41 | --token "$TOKEN" 42 | ;; 43 | *) 44 | echo "Unkown action $ACTION" 45 | exit 1 46 | esac 47 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: GitOps Promotion 2 | author: https://github.com/XenitAB 3 | description: | 4 | A tool to do automatic promotion with a GitOps workflow. 5 | inputs: 6 | action: 7 | description: > 8 | Action to perform; one of "new", "feature", "promote" or "status". See 9 | https://github.com/XenitAB/gitops-promotion/README.md for details. 10 | required: true 11 | token: 12 | description: Access token (PAT) to git provider. You probably want secrets.GITHUB_TOKEN 13 | required: true 14 | group: 15 | description: Main application group; relevant when action is "new" or "feature" 16 | required: false 17 | app: 18 | description: Name of the application; relevant when action is "new" or "feature" 19 | required: false 20 | tag: 21 | description: Application version/tag to set; relevant when action is "new" or "feature" 22 | required: false 23 | feature: 24 | description: Feature name; relevant when action is "feature" 25 | required: false 26 | runs: 27 | using: docker 28 | image: docker://ghcr.io/xenitab/gitops-promotion:v1.3.1 29 | entrypoint: /usr/local/bin/action-entrypoint.sh 30 | env: 31 | ACTION: ${{ inputs.action }} 32 | TOKEN: ${{ inputs.token }} 33 | GROUP: ${{ inputs.group }} 34 | APP: ${{ inputs.app }} 35 | TAG: ${{ inputs.tag }} 36 | FEATURE: ${{ inputs.feature }} 37 | -------------------------------------------------------------------------------- /assets/kustomization-checks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XenitAB/gitops-promotion/1feb7d7ef78c593f79f88ca8fb09fdb98a5fe7cd/assets/kustomization-checks.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/xenitab/gitops-promotion 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/avast/retry-go v3.0.0+incompatible 7 | github.com/fluxcd/image-automation-controller v0.19.0 8 | github.com/fluxcd/image-reflector-controller/api v0.15.0 9 | github.com/go-logr/logr v1.2.2 10 | github.com/google/go-github/v45 v45.2.0 11 | github.com/google/uuid v1.3.0 12 | github.com/jfrog/jfrog-client-go v1.7.1 13 | github.com/libgit2/git2go/v33 v33.0.9 14 | github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 15 | github.com/onsi/ginkgo v1.16.5 16 | github.com/onsi/gomega v1.17.0 17 | github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00 18 | github.com/spf13/afero v1.6.0 19 | github.com/spf13/pflag v1.0.5 20 | github.com/stretchr/testify v1.7.0 21 | github.com/whilp/git-urls v1.0.0 22 | golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 23 | gopkg.in/yaml.v2 v2.4.0 24 | k8s.io/api v0.23.1 25 | k8s.io/apimachinery v0.23.1 26 | sigs.k8s.io/kustomize/api v0.10.1 27 | sigs.k8s.io/kustomize/kyaml v0.13.0 28 | sigs.k8s.io/yaml v1.3.0 29 | ) 30 | 31 | require ( 32 | github.com/PuerkitoBio/purell v1.1.1 // indirect 33 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 34 | github.com/andybalholm/brotli v1.0.2 // indirect 35 | github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect 36 | github.com/davecgh/go-spew v1.1.1 // indirect 37 | github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect 38 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 39 | github.com/fluxcd/pkg/apis/meta v0.10.2 // indirect 40 | github.com/fsnotify/fsnotify v1.5.1 // indirect 41 | github.com/go-errors/errors v1.1.1 // indirect 42 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 43 | github.com/go-openapi/jsonreference v0.19.5 // indirect 44 | github.com/go-openapi/swag v0.19.15 // indirect 45 | github.com/gogo/protobuf v1.3.2 // indirect 46 | github.com/golang/protobuf v1.5.2 // indirect 47 | github.com/golang/snappy v0.0.3 // indirect 48 | github.com/google/go-cmp v0.5.8 // indirect 49 | github.com/google/go-containerregistry v0.6.0 // indirect 50 | github.com/google/go-querystring v1.1.0 // indirect 51 | github.com/google/gofuzz v1.2.0 // indirect 52 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 53 | github.com/gookit/color v1.4.2 // indirect 54 | github.com/imdario/mergo v0.3.12 // indirect 55 | github.com/jfrog/build-info-go v0.1.6 // indirect 56 | github.com/jfrog/gofrog v1.1.1 // indirect 57 | github.com/josharian/intern v1.0.0 // indirect 58 | github.com/json-iterator/go v1.1.12 // indirect 59 | github.com/klauspost/compress v1.14.1 // indirect 60 | github.com/klauspost/pgzip v1.2.5 // indirect 61 | github.com/mailru/easyjson v0.7.7 // indirect 62 | github.com/mholt/archiver/v3 v3.5.1-0.20210618180617-81fac4ba96e4 // indirect 63 | github.com/mitchellh/mapstructure v1.4.3 // indirect 64 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 65 | github.com/modern-go/reflect2 v1.0.2 // indirect 66 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 67 | github.com/nwaples/rardecode v1.1.0 // indirect 68 | github.com/nxadm/tail v1.4.8 // indirect 69 | github.com/pierrec/lz4/v4 v4.1.6 // indirect 70 | github.com/pkg/errors v0.9.1 // indirect 71 | github.com/pmezard/go-difflib v1.0.0 // indirect 72 | github.com/sergi/go-diff v1.2.0 // indirect 73 | github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect 74 | github.com/ulikunitz/xz v0.5.10 // indirect 75 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect 76 | github.com/xlab/treeprint v1.1.0 // indirect 77 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect 78 | go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect 79 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect 80 | golang.org/x/net v0.9.0 // indirect 81 | golang.org/x/sys v0.7.0 // indirect 82 | golang.org/x/term v0.7.0 // indirect 83 | golang.org/x/text v0.9.0 // indirect 84 | google.golang.org/appengine v1.6.7 // indirect 85 | google.golang.org/protobuf v1.27.1 // indirect 86 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 87 | gopkg.in/inf.v0 v0.9.1 // indirect 88 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 89 | gopkg.in/yaml.v3 v3.0.1 // indirect 90 | k8s.io/klog/v2 v2.30.0 // indirect 91 | k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect 92 | k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 // indirect 93 | sigs.k8s.io/controller-runtime v0.11.0 // indirect 94 | sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect 95 | sigs.k8s.io/structured-merge-diff/v4 v4.2.0 // indirect 96 | ) 97 | 98 | // side-effect of depending on source-controller 99 | // required by https://github.com/helm/helm/blob/v3.5.2/go.mod 100 | replace ( 101 | github.com/docker/distribution => github.com/docker/distribution v0.0.0-20191216044856-a8371794149d 102 | github.com/docker/docker => github.com/moby/moby v17.12.0-ce-rc1.0.20200618181300-9dc6525e6118+incompatible 103 | ) 104 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/xenitab/gitops-promotion/pkg/command" 9 | ) 10 | 11 | func main() { 12 | message, err := command.Run(context.Background(), os.Args) 13 | if message != "" { 14 | fmt.Println(message) 15 | } 16 | if err != nil { 17 | fmt.Fprintf(os.Stderr, "Application failed with error: %v\n", err) 18 | os.Exit(1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestCIRequirements(t *testing.T) { 9 | ciEnvVar := os.Getenv("CI") 10 | if ciEnvVar != "true" { 11 | t.Skipf("CI environment variable not set to true: %s", ciEnvVar) 12 | } 13 | 14 | reqEnvVars := []string{ 15 | "AZDO_URL", 16 | "AZDO_PAT", 17 | "GITHUB_URL", 18 | "GITHUB_TOKEN", 19 | } 20 | 21 | for _, envVar := range reqEnvVars { 22 | v := os.Getenv(envVar) 23 | if v == "" { 24 | t.Errorf("%s environment variable is required by CI.", envVar) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/jfrog/jfrog-client-go/utils/io/fileutils" 11 | flag "github.com/spf13/pflag" 12 | 13 | "github.com/xenitab/gitops-promotion/pkg/config" 14 | "github.com/xenitab/gitops-promotion/pkg/git" 15 | ) 16 | 17 | const ( 18 | configFileName = "gitops-promotion.yaml" 19 | ) 20 | 21 | //nolint:funlen,cyclop,gocognit // ignore 22 | func Run(ctx context.Context, args []string) (string, error) { 23 | if len(args) < 2 { 24 | return "", fmt.Errorf("new, feature, promote, or status subcommand is required") 25 | } 26 | 27 | // Global flags 28 | defaultPath, err := os.Getwd() 29 | if err != nil { 30 | return "", err 31 | } 32 | global := flag.NewFlagSet(args[1], flag.ExitOnError) 33 | global.ParseErrorsWhitelist = flag.ParseErrorsWhitelist{UnknownFlags: true} 34 | token := global.String("token", "", "Access token (PAT) to git provider") 35 | providerType := global.String("provider", "azdo", "The git provider to use") 36 | path := global.String("sourcedir", defaultPath, "Source working tree to operate on") 37 | err = global.Parse(args[2:]) 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | // Load configuration 43 | file, err := os.Open(filepath.Join(*path, configFileName)) 44 | if err != nil { 45 | return "", err 46 | } 47 | cfg, err := config.LoadConfig(file) 48 | if err != nil { 49 | return "", fmt.Errorf("could not load config: %w", err) 50 | } 51 | 52 | // Load repository 53 | tmpPath, err := os.MkdirTemp("", "gitops-promotion-") 54 | if err != nil { 55 | return "", err 56 | } 57 | defer func() { 58 | err := fileutils.RemovePath(tmpPath) 59 | if err != nil { 60 | fmt.Fprintf(os.Stderr, "Unable to remove path %q, returned error: %s", tmpPath, err) 61 | } 62 | }() 63 | err = fileutils.CopyDir(*path, tmpPath, true, []string{}) 64 | if err != nil { 65 | return "", err 66 | } 67 | repo, err := git.LoadRepository(ctx, tmpPath, *providerType, *token) 68 | if err != nil { 69 | return "", fmt.Errorf("could not load %s repository: %w", *providerType, err) 70 | } 71 | 72 | // Run Command 73 | switch args[1] { 74 | case "new": 75 | newCommand := flag.NewFlagSet(args[1], flag.ExitOnError) 76 | newCommand.ParseErrorsWhitelist = flag.ParseErrorsWhitelist{UnknownFlags: true} 77 | group := newCommand.String("group", "", "Main application group") 78 | app := newCommand.String("app", "", "Name of the application") 79 | tag := newCommand.String("tag", "", "Application version/tag to set") 80 | err := newCommand.Parse(args[2:]) 81 | if err != nil { 82 | return "", err 83 | } 84 | return NewCommand(ctx, cfg, repo, *group, *app, *tag) 85 | case "feature": 86 | featureCommand := flag.NewFlagSet(args[1], flag.ContinueOnError) 87 | featureCommand.ParseErrorsWhitelist = flag.ParseErrorsWhitelist{UnknownFlags: true} 88 | group := featureCommand.String("group", "", "Main application group") 89 | app := featureCommand.String("app", "", "Name of the application") 90 | tag := featureCommand.String("tag", "", "Application version/tag to set") 91 | feature := featureCommand.String("feature", "", "Application feature") 92 | err := featureCommand.Parse(args[2:]) 93 | if err != nil { 94 | return "", err 95 | } 96 | return FeatureNewCommand(ctx, cfg, repo, *group, *app, *tag, *feature) 97 | case "feature-stale": 98 | featureStaleCommand := flag.NewFlagSet(args[1], flag.ExitOnError) 99 | featureStaleCommand.ParseErrorsWhitelist = flag.ParseErrorsWhitelist{UnknownFlags: true} 100 | maxAge := featureStaleCommand.Duration("max-age", 7*24*time.Hour, 101 | "Threshold for when the last commit to a feature application is considered stale.") 102 | err := featureStaleCommand.Parse(args[2:]) 103 | if err != nil { 104 | return "", err 105 | } 106 | return FeatureDeleteStaleCommand(ctx, cfg, repo, *maxAge) 107 | case "promote": 108 | return PromoteCommand(ctx, cfg, repo) 109 | case "status": 110 | return StatusCommand(ctx, cfg, repo) 111 | default: 112 | return "", fmt.Errorf("Unknown command: %s", args[1]) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pkg/command/feature.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/spf13/afero" 12 | 13 | "github.com/xenitab/gitops-promotion/pkg/config" 14 | "github.com/xenitab/gitops-promotion/pkg/git" 15 | "github.com/xenitab/gitops-promotion/pkg/manifest" 16 | ) 17 | 18 | // FeatureNewCommand is similar to NewCommand but creates a PR with a temporary deployment of the application. 19 | // A totally new application will be created instead of overriding the existing application deployment. 20 | func FeatureNewCommand(ctx context.Context, cfg config.Config, repo *git.Repository, group, app, tag, feature string) (string, error) { 21 | // The feature name has to be alpha numeric or "-" as both Kubernetes 22 | // resources and domain names have this requirement. For this reason the 23 | // inputed feature name is sanitized to remove any offending characters 24 | // and lowercase all characters. 25 | reg := regexp.MustCompile("[^a-zA-Z0-9-]+") 26 | feature = reg.ReplaceAllString(feature, "") 27 | feature = strings.ToLower(feature) 28 | state := git.PRState{ 29 | Env: cfg.Environments[0].Name, 30 | Group: group, 31 | App: app, 32 | Tag: tag, 33 | Sha: "", 34 | Feature: feature, 35 | Type: git.PRTypeFeature, 36 | } 37 | 38 | // If feature overwrite is enabled the application image with be changed like a normal deployment 39 | // but it will not be promoted. 40 | featureOverwrite, err := cfg.HasFeatureOverwrite(state.Group, app) 41 | if err != nil { 42 | return "", err 43 | } 44 | if featureOverwrite { 45 | return promote(ctx, cfg, repo, &state) 46 | } 47 | 48 | featureLabelSelector, err := cfg.GetFeatureLabelSelector(state.Group, app) 49 | if err != nil { 50 | return "", fmt.Errorf("feature deployment does not work without configuring a feature label selector: %w", err) 51 | } 52 | fs := afero.NewBasePathFs(afero.NewOsFs(), repo.GetRootDir()) 53 | err = manifest.DuplicateApplication(fs, state, featureLabelSelector) 54 | if err != nil { 55 | return "", err 56 | } 57 | branchName := state.BranchName(false) 58 | title := state.Title() 59 | description, err := state.Description() 60 | if err != nil { 61 | return "", err 62 | } 63 | err = repo.CreateBranch(branchName, true) 64 | if err != nil { 65 | return "", fmt.Errorf("could not create branch: %w", err) 66 | } 67 | sha, err := repo.CreateCommit(branchName, title) 68 | if err != nil { 69 | return "", fmt.Errorf("could not commit changes: %w", err) 70 | } 71 | err = repo.Push(branchName, true) 72 | if err != nil { 73 | return "", fmt.Errorf("could not push changes: %w", err) 74 | } 75 | auto, err := cfg.IsEnvironmentAutomated(state.Env) 76 | if err != nil { 77 | return "", fmt.Errorf("could not get environment automation state: %w", err) 78 | } 79 | prid, err := repo.CreatePR(ctx, branchName, auto, title, description) 80 | if err != nil { 81 | return "", fmt.Errorf("could not create a PR: %w", err) 82 | } 83 | return fmt.Sprintf("created branch %s with pull request %d on commit %s", branchName, prid, sha), nil 84 | } 85 | 86 | //nolint:gocognit,cyclop // ignore 87 | func FeatureDeleteStaleCommand(ctx context.Context, cfg config.Config, repo *git.Repository, maxAge time.Duration) (string, error) { 88 | environmentName := cfg.Environments[0].Name 89 | fs := afero.NewBasePathFs(afero.NewOsFs(), repo.GetRootDir()) 90 | 91 | // Find directory names that are feature deployments 92 | states := []git.PRState{} 93 | for groupKey, group := range cfg.Groups { 94 | for appKey := range group.Applications { 95 | globKey := fmt.Sprintf("%s-*", appKey) 96 | matches, err := afero.Glob(fs, filepath.Join(groupKey, environmentName, globKey)) 97 | if err != nil { 98 | return "", err 99 | } 100 | for _, match := range matches { 101 | // Skip non directories 102 | if fi, err := fs.Stat(match); err != nil && !fi.IsDir() { 103 | continue 104 | } 105 | 106 | // TODO: Replace with strings.Cut in Go 1.18 107 | comps := strings.Split(match, "-") 108 | feature := strings.Join(comps[1:], "-") 109 | 110 | state := git.PRState{ 111 | Group: groupKey, 112 | App: appKey, 113 | Env: environmentName, 114 | Feature: feature, 115 | Type: git.PRTypeFeature, 116 | } 117 | states = append(states, state) 118 | } 119 | } 120 | } 121 | 122 | // Remove feature directories that have not been committed to for longer than max age 123 | removedApplication := false 124 | //nolint:gocritic // ignore 125 | for _, state := range states { 126 | commit, err := repo.GetLastCommitForPath(state.AppPath()) 127 | if err != nil { 128 | return "", err 129 | } 130 | if time.Since(commit.Author().When) < maxAge { 131 | continue 132 | } 133 | err = manifest.RemoveApplication(fs, state) 134 | if err != nil { 135 | return "", fmt.Errorf("could not remove application: %w", err) 136 | } 137 | removedApplication = true 138 | } 139 | if !removedApplication { 140 | return "No stale application to remove, exiting early.", nil 141 | } 142 | 143 | // Commit, push branch, create PR 144 | branchName := "remove/stale-feature" 145 | err := repo.CreateBranch(branchName, true) 146 | if err != nil { 147 | return "", fmt.Errorf("could not create branch: %w", err) 148 | } 149 | title := "Remove stale review features" 150 | description := "" 151 | sha, err := repo.CreateCommit(branchName, title) 152 | if err != nil { 153 | return "", fmt.Errorf("could not commit changes: %w", err) 154 | } 155 | err = repo.Push(branchName, true) 156 | if err != nil { 157 | return "", fmt.Errorf("could not push changes: %w", err) 158 | } 159 | auto, err := cfg.IsEnvironmentAutomated(environmentName) 160 | if err != nil { 161 | return "", fmt.Errorf("could not get environment automation state: %w", err) 162 | } 163 | prid, err := repo.CreatePR(ctx, branchName, auto, title, description) 164 | if err != nil { 165 | return "", fmt.Errorf("could not create a PR: %w", err) 166 | } 167 | return fmt.Sprintf("created branch %s with pull request %d on commit %s", branchName, prid, sha), nil 168 | } 169 | -------------------------------------------------------------------------------- /pkg/command/new.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/xenitab/gitops-promotion/pkg/config" 8 | "github.com/xenitab/gitops-promotion/pkg/git" 9 | ) 10 | 11 | // NewCommand creates the initial PR which is going to be merged to the first environment. The main 12 | // difference to PromoteCommand is that it does not use a previous PR to create the first PR. 13 | func NewCommand(ctx context.Context, cfg config.Config, repo *git.Repository, group, app, tag string) (string, error) { 14 | headID, err := repo.GetCurrentCommit() 15 | if err != nil { 16 | return "", fmt.Errorf("could not get latest commit: %w", err) 17 | } 18 | state := git.PRState{ 19 | Group: group, 20 | App: app, 21 | Tag: tag, 22 | Env: cfg.Environments[0].Name, 23 | Sha: headID.String(), 24 | Type: git.PRTypePromote, 25 | } 26 | return promote(ctx, cfg, repo, &state) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/command/promote.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/xenitab/gitops-promotion/pkg/config" 9 | "github.com/xenitab/gitops-promotion/pkg/git" 10 | "github.com/xenitab/gitops-promotion/pkg/manifest" 11 | ) 12 | 13 | // PromoteCommand is run after a PR is merged. It creates a new PR for the next environment 14 | // if there is one present. 15 | func PromoteCommand(ctx context.Context, cfg config.Config, repo *git.Repository) (string, error) { 16 | pr, err := repo.GetPRThatCausedCurrentCommit(ctx) 17 | if err != nil { 18 | //nolint:errcheck //best effort for logging 19 | sha, _ := repo.GetCurrentCommit() 20 | log.Printf("Failed retrieving pull request for commit %s: %v", sha, err) 21 | //lint:ignore nilerr should not return error 22 | return "skipping promotion as commit does not originate from PR", nil 23 | } 24 | if pr.State == nil { 25 | return "skipping promotion as PR is not created by gitops-promotion", nil 26 | } 27 | if pr.State.GetPRType() == git.PRTypeFeature { 28 | return "skipping promotion of feature", nil 29 | } 30 | if !cfg.HasNextEnvironment(pr.State.Env) { 31 | return "no next environment to promote to", nil 32 | } 33 | headID, err := repo.GetCurrentCommit() 34 | if err != nil { 35 | return "", fmt.Errorf("could not get latest commit: %w", err) 36 | } 37 | nextEnv, err := cfg.NextEnvironment(pr.State.Env) 38 | if err != nil { 39 | return "", fmt.Errorf("could not get next environment: %w", err) 40 | } 41 | state := &git.PRState{ 42 | Group: pr.State.Group, 43 | App: pr.State.App, 44 | Tag: pr.State.Tag, 45 | Env: nextEnv.Name, 46 | Sha: headID.String(), 47 | Type: pr.State.Type, 48 | } 49 | return promote(ctx, cfg, repo, state) 50 | } 51 | 52 | func promote(ctx context.Context, cfg config.Config, repo *git.Repository, state *git.PRState) (string, error) { 53 | // Update image tag 54 | manifestPath := fmt.Sprintf("%s/%s/%s", repo.GetRootDir(), state.Group, state.Env) 55 | err := manifest.UpdateImageTag(manifestPath, state.App, state.Group, state.Tag) 56 | if err != nil { 57 | return "", fmt.Errorf("failed updating manifests: %w", err) 58 | } 59 | 60 | // Push and create PR 61 | branchName := state.BranchName(cfg.PRFlow == "per-env") 62 | title := state.Title() 63 | description, err := state.Description() 64 | if err != nil { 65 | return "", err 66 | } 67 | err = repo.CreateBranch(branchName, true) 68 | if err != nil { 69 | return "", fmt.Errorf("could not create branch: %w", err) 70 | } 71 | sha, err := repo.CreateCommit(branchName, title) 72 | if err != nil { 73 | return "", fmt.Errorf("could not commit changes: %w", err) 74 | } 75 | err = repo.Push(branchName, true) 76 | if err != nil { 77 | return "", fmt.Errorf("could not push changes: %w", err) 78 | } 79 | auto, err := cfg.IsEnvironmentAutomated(state.Env) 80 | if err != nil { 81 | return "", fmt.Errorf("could not get environment automation state: %w", err) 82 | } 83 | prid, err := repo.CreatePR(ctx, branchName, auto, title, description) 84 | if err != nil { 85 | return "", fmt.Errorf("could not create a PR: %w", err) 86 | } 87 | return fmt.Sprintf("created branch %s with pull request %d on commit %s", branchName, prid, sha), nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/command/status.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/xenitab/gitops-promotion/pkg/config" 10 | "github.com/xenitab/gitops-promotion/pkg/git" 11 | ) 12 | 13 | // StatusCommand is run inside a PR to check if the PR can be merged. 14 | // 15 | //nolint:gocognit // not convinced that extracting bits would make it more readable 16 | func StatusCommand(ctx context.Context, cfg config.Config, repo *git.Repository) (string, error) { 17 | // If branch does not contain promote it was manual, return early 18 | branchName, err := repo.GetBranchName() 19 | if err != nil { 20 | return "", fmt.Errorf("failed to find current branch: %w", err) 21 | } 22 | if !strings.HasPrefix(branchName, string(git.PRTypePromote)) { 23 | return "Promotion was manual, skipping check", nil 24 | } 25 | 26 | // get current pr 27 | pr, err := repo.GetPRForCurrentBranch(ctx) 28 | if err != nil { 29 | return "", fmt.Errorf("failed getting pr for current branch: %w", err) 30 | } 31 | if pr.State.GetPRType() == git.PRTypeFeature { 32 | return "Automatically allowing feature branch PR", nil 33 | } 34 | 35 | // Skip the status check if this is the first environment 36 | if cfg.Environments[0].Name == pr.State.Env { 37 | return fmt.Sprintf("%q is the first environment so status check is skipped", pr.State.Env), nil 38 | } 39 | 40 | // Check status of commit 41 | prevEnv, err := cfg.PrevEnvironment(pr.State.Env) 42 | if err != nil { 43 | return "", err 44 | } 45 | deadline := time.Now().Add(cfg.StatusTimeout) 46 | for { 47 | if time.Now().After(deadline) { 48 | break 49 | } 50 | status, err := repo.GetStatus(ctx, pr.State.Sha, pr.State.Group, prevEnv.Name) 51 | if err == nil { 52 | if !status.Succeeded { 53 | return "", fmt.Errorf("failed reconciliation for %s-%s found on %q", pr.State.Group, prevEnv.Name, pr.State.Sha) 54 | } 55 | return fmt.Sprintf("successful reconciliation for %s-%s found on %q", pr.State.Group, prevEnv.Name, pr.State.Sha), nil 56 | } 57 | head, err := repo.FetchBranch(git.DefaultBranch) 58 | if err != nil { 59 | return "", fmt.Errorf("failed to fetch new commits: %w", err) 60 | } 61 | status, err = repo.GetStatus(ctx, head.String(), pr.State.Group, prevEnv.Name) 62 | if err == nil { 63 | if !status.Succeeded { 64 | return "", fmt.Errorf("failed reconciliation for %s-%s found on %s at %s", pr.State.Group, prevEnv.Name, git.DefaultBranch, head) 65 | } 66 | return fmt.Sprintf("successful reconciliation for %s-%s found on %s at %s", pr.State.Group, prevEnv.Name, git.DefaultBranch, head), nil 67 | } 68 | fmt.Printf("retrying status check for %s-%s: %v\n", pr.State.Group, prevEnv.Name, err) 69 | time.Sleep(5 * time.Second) 70 | } 71 | return "", fmt.Errorf("commit status check for %s-%s has timed out %q", pr.State.Group, prevEnv.Name, pr.State.Sha) 72 | } 73 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "gopkg.in/yaml.v2" 9 | ) 10 | 11 | type PRFlowType string 12 | 13 | const ( 14 | PRFlowTypePerApp PRFlowType = "per-app" 15 | PRFlowTypePerEnv PRFlowType = "per-env" 16 | ) 17 | 18 | type App struct { 19 | FeatureOverwrite bool `yaml:"featureOverwrite"` 20 | FeatureLabelSelector map[string]string `yaml:"featureLabelSelector"` 21 | } 22 | 23 | type Group struct { 24 | Applications map[string]App `yaml:"applications"` 25 | } 26 | 27 | type Environment struct { 28 | Name string `yaml:"name"` 29 | Automated bool `yaml:"auto"` 30 | } 31 | 32 | type Config struct { 33 | PRFlow PRFlowType `yaml:"prflow"` 34 | StatusTimeout time.Duration `yaml:"status_timeout_minutes"` 35 | Environments []Environment `yaml:"environments"` 36 | Groups map[string]Group `yaml:"groups"` 37 | } 38 | 39 | func LoadConfig(file io.Reader) (Config, error) { 40 | cfg := Config{} 41 | decoder := yaml.NewDecoder(file) 42 | err := decoder.Decode(&cfg) 43 | if err != nil { 44 | return Config{}, err 45 | } 46 | 47 | if len(cfg.Environments) == 0 { 48 | return Config{}, fmt.Errorf("environments list cannot be empty") 49 | } 50 | if cfg.PRFlow == "" { 51 | cfg.PRFlow = PRFlowTypePerApp 52 | } 53 | if cfg.StatusTimeout.String() == (0 * time.Minute).String() { 54 | cfg.StatusTimeout = 5 * time.Minute 55 | } 56 | switch cfg.PRFlow { 57 | case PRFlowTypePerApp, PRFlowTypePerEnv: 58 | break 59 | default: 60 | return Config{}, fmt.Errorf("invalid prflow value: %s", cfg.PRFlow) 61 | } 62 | 63 | return cfg, nil 64 | } 65 | 66 | func (c Config) HasNextEnvironment(name string) bool { 67 | last := len(c.Environments) - 1 68 | return c.Environments[last].Name != name 69 | } 70 | 71 | func (c Config) NextEnvironment(name string) (Environment, error) { 72 | _, i, err := c.getEnvironment(name) 73 | if err != nil { 74 | return Environment{}, err 75 | } 76 | if i == len(c.Environments)-1 { 77 | return Environment{}, fmt.Errorf("last environment cannot have a next environment") 78 | } 79 | return c.Environments[i+1], nil 80 | } 81 | 82 | func (c Config) PrevEnvironment(name string) (Environment, error) { 83 | _, i, err := c.getEnvironment(name) 84 | if err != nil { 85 | return Environment{}, err 86 | } 87 | if i == 0 { 88 | return Environment{}, fmt.Errorf("first environment cannot have a previous environment") 89 | } 90 | return c.Environments[i-1], nil 91 | } 92 | 93 | func (c Config) IsEnvironmentAutomated(name string) (bool, error) { 94 | e, _, err := c.getEnvironment(name) 95 | if err != nil { 96 | return false, err 97 | } 98 | return e.Automated, nil 99 | } 100 | 101 | func (c Config) IsAnyEnvironmentManual() bool { 102 | for _, e := range c.Environments { 103 | if !e.Automated { 104 | return true 105 | } 106 | } 107 | return false 108 | } 109 | 110 | func (c Config) HasFeatureOverwrite(group, app string) (bool, error) { 111 | groupObj, ok := c.Groups[group] 112 | if !ok { 113 | return false, fmt.Errorf("configuration does not contain group %s", group) 114 | } 115 | appObj, ok := groupObj.Applications[app] 116 | if !ok { 117 | return false, fmt.Errorf("configuration for group %s does not contain application %s", group, app) 118 | } 119 | return appObj.FeatureOverwrite, nil 120 | } 121 | 122 | func (c Config) GetFeatureLabelSelector(group, app string) (map[string]string, error) { 123 | groupObj, ok := c.Groups[group] 124 | if !ok { 125 | return nil, fmt.Errorf("configuration does not contain group %s", group) 126 | } 127 | appObj, ok := groupObj.Applications[app] 128 | if !ok { 129 | return nil, fmt.Errorf("configuration for group %s does not contain application %s", group, app) 130 | } 131 | return appObj.FeatureLabelSelector, nil 132 | } 133 | 134 | func (c Config) getEnvironment(name string) (Environment, int, error) { 135 | for i, e := range c.Environments { 136 | if e.Name == name { 137 | return e, i, nil 138 | } 139 | } 140 | return Environment{}, 0, fmt.Errorf("environment named %s does not exist", name) 141 | } 142 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | const simpleData = ` 12 | environments: 13 | - name: dev 14 | auto: true 15 | - name: qa 16 | auto: true 17 | - name: prod 18 | auto: false 19 | ` 20 | 21 | func TestConfigParse(t *testing.T) { 22 | reader := bytes.NewReader([]byte(simpleData)) 23 | cfg, err := LoadConfig(reader) 24 | require.NoError(t, err) 25 | require.Len(t, cfg.Environments, 3) 26 | require.Equal(t, PRFlowTypePerApp, cfg.PRFlow) 27 | require.True(t, cfg.IsAnyEnvironmentManual()) 28 | } 29 | 30 | func TestConfigHasNext(t *testing.T) { 31 | reader := bytes.NewReader([]byte(simpleData)) 32 | cfg, err := LoadConfig(reader) 33 | require.NoError(t, err) 34 | cases := []struct { 35 | environment string 36 | hasNext bool 37 | }{ 38 | { 39 | environment: "dev", 40 | hasNext: true, 41 | }, 42 | { 43 | environment: "qa", 44 | hasNext: true, 45 | }, 46 | { 47 | environment: "prod", 48 | hasNext: false, 49 | }, 50 | } 51 | for _, c := range cases { 52 | t.Run(c.environment, func(t *testing.T) { 53 | require.Equal(t, c.hasNext, cfg.HasNextEnvironment(c.environment)) 54 | }) 55 | } 56 | } 57 | 58 | func TestConfigIsAutomated(t *testing.T) { 59 | reader := bytes.NewReader([]byte(simpleData)) 60 | cfg, err := LoadConfig(reader) 61 | require.NoError(t, err) 62 | cases := []struct { 63 | environment string 64 | isAutomated bool 65 | }{ 66 | { 67 | environment: "dev", 68 | isAutomated: true, 69 | }, 70 | { 71 | environment: "qa", 72 | isAutomated: true, 73 | }, 74 | { 75 | environment: "prod", 76 | isAutomated: false, 77 | }, 78 | } 79 | for _, c := range cases { 80 | t.Run(c.environment, func(t *testing.T) { 81 | automated, err := cfg.IsEnvironmentAutomated(c.environment) 82 | require.NoError(t, err) 83 | require.Equal(t, c.isAutomated, automated) 84 | }) 85 | } 86 | } 87 | 88 | func TestConfigNextPrev(t *testing.T) { 89 | reader := bytes.NewReader([]byte(simpleData)) 90 | cfg, err := LoadConfig(reader) 91 | require.NoError(t, err) 92 | cases := []struct { 93 | environment string 94 | nextEnvironment string 95 | prevEnvironment string 96 | }{ 97 | { 98 | environment: "dev", 99 | nextEnvironment: "qa", 100 | }, 101 | { 102 | environment: "qa", 103 | nextEnvironment: "prod", 104 | prevEnvironment: "dev", 105 | }, 106 | { 107 | environment: "prod", 108 | prevEnvironment: "qa", 109 | }, 110 | } 111 | for _, c := range cases { 112 | t.Run(c.environment, func(t *testing.T) { 113 | e, err := cfg.NextEnvironment(c.environment) 114 | if cfg.Environments[len(cfg.Environments)-1].Name == c.environment { 115 | require.EqualError(t, err, "last environment cannot have a next environment") 116 | } else { 117 | require.Equal(t, c.nextEnvironment, e.Name) 118 | } 119 | 120 | e, err = cfg.PrevEnvironment(c.environment) 121 | if cfg.Environments[0].Name == c.environment { 122 | require.EqualError(t, err, "first environment cannot have a previous environment") 123 | } else { 124 | require.Equal(t, c.prevEnvironment, e.Name) 125 | } 126 | }) 127 | } 128 | } 129 | 130 | func TestConfigNotFound(t *testing.T) { 131 | reader := bytes.NewReader([]byte(simpleData)) 132 | cfg, err := LoadConfig(reader) 133 | require.NoError(t, err) 134 | _, err = cfg.IsEnvironmentAutomated("foobar") 135 | require.EqualError(t, err, "environment named foobar does not exist") 136 | _, err = cfg.NextEnvironment("foobar") 137 | require.EqualError(t, err, "environment named foobar does not exist") 138 | _, err = cfg.PrevEnvironment("foobar") 139 | require.EqualError(t, err, "environment named foobar does not exist") 140 | } 141 | 142 | func TestConfigEmptyEnvironments(t *testing.T) { 143 | data := ` 144 | environments: [] 145 | ` 146 | reader := bytes.NewReader([]byte(data)) 147 | _, err := LoadConfig(reader) 148 | require.EqualError(t, err, "environments list cannot be empty") 149 | } 150 | 151 | func TestConfigPRFlowEnv(t *testing.T) { 152 | data := ` 153 | environments: 154 | - name: dev 155 | auto: true 156 | prflow: per-env 157 | ` 158 | reader := bytes.NewReader([]byte(data)) 159 | cfg, err := LoadConfig(reader) 160 | require.NoError(t, err) 161 | require.Equal(t, PRFlowTypePerEnv, cfg.PRFlow) 162 | } 163 | 164 | func TestConfigPRFlowInvalid(t *testing.T) { 165 | data := ` 166 | environments: 167 | - name: dev 168 | auto: true 169 | prflow: foobar 170 | ` 171 | reader := bytes.NewReader([]byte(data)) 172 | _, err := LoadConfig(reader) 173 | require.EqualError(t, err, "invalid prflow value: foobar") 174 | } 175 | 176 | func TestConfigStatusTimeout(t *testing.T) { 177 | data := ` 178 | environments: 179 | - name: dev 180 | auto: true 181 | ` 182 | reader := bytes.NewReader([]byte(data)) 183 | cfg, err := LoadConfig(reader) 184 | require.NoError(t, err) 185 | require.Equal(t, 5*time.Minute, cfg.StatusTimeout) 186 | } 187 | 188 | func TestConfigFeature(t *testing.T) { 189 | data := ` 190 | environments: 191 | - name: dev 192 | auto: true 193 | groups: 194 | apps: 195 | applications: 196 | podinfo: 197 | featureLabelSelector: 198 | app: podinfo 199 | 200 | ` 201 | reader := bytes.NewReader([]byte(data)) 202 | cfg, err := LoadConfig(reader) 203 | require.NoError(t, err) 204 | require.NotEmpty(t, cfg.Groups) 205 | 206 | featureLabelSelector, err := cfg.GetFeatureLabelSelector("apps", "podinfo") 207 | require.NoError(t, err) 208 | require.Equal(t, map[string]string{"app": "podinfo"}, featureLabelSelector) 209 | } 210 | -------------------------------------------------------------------------------- /pkg/git/azdo.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/microsoft/azure-devops-go-api/azuredevops" 12 | "github.com/microsoft/azure-devops-go-api/azuredevops/git" 13 | "github.com/microsoft/azure-devops-go-api/azuredevops/webapi" 14 | ) 15 | 16 | // AdoGITProvider ... 17 | type AzdoGITProvider struct { 18 | client git.Client 19 | proj string 20 | repo string 21 | } 22 | 23 | // NewAdoGITProvider ... 24 | func NewAzdoGITProvider(ctx context.Context, remoteURL, token string) (*AzdoGITProvider, error) { 25 | host, id, err := ParseGitAddress(remoteURL) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | var org string 31 | var proj string 32 | var repo string 33 | if host == "https://dev.azure.com" { 34 | comp := strings.Split(id, "/") 35 | if len(comp) != 4 { 36 | return nil, fmt.Errorf("invalid repository id %q", id) 37 | } 38 | org = comp[0] 39 | proj = comp[1] 40 | repo = comp[3] 41 | } else { 42 | comp := strings.Split(id, "/") 43 | if len(comp) != 3 { 44 | return nil, fmt.Errorf("invalid repository id %q", id) 45 | } 46 | proj = comp[0] 47 | repo = comp[2] 48 | 49 | u, err := url.Parse(host) 50 | if err != nil { 51 | return nil, err 52 | } 53 | comp = strings.Split(u.Hostname(), ".") 54 | org = comp[0] 55 | host = "https://dev.azure.com" 56 | } 57 | 58 | connection := azuredevops.NewPatConnection(fmt.Sprintf("%s/%s", host, org), token) 59 | client, err := git.NewClient(ctx, connection) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return &AzdoGITProvider{ 64 | client: client, 65 | proj: proj, 66 | repo: repo, 67 | }, nil 68 | } 69 | 70 | // CreatePR ... 71 | func (g *AzdoGITProvider) CreatePR(ctx context.Context, branchName string, auto bool, title, description string) (int, error) { 72 | sourceRefName := fmt.Sprintf("refs/heads/%s", branchName) 73 | targetRefName := fmt.Sprintf("refs/heads/%s", DefaultBranch) 74 | 75 | // Update PR if it already exists 76 | getArgs := git.GetPullRequestsArgs{ 77 | Project: &g.proj, 78 | RepositoryId: &g.repo, 79 | SearchCriteria: &git.GitPullRequestSearchCriteria{ 80 | SourceRefName: &sourceRefName, 81 | TargetRefName: &targetRefName, 82 | }, 83 | } 84 | prs, err := g.client.GetPullRequests(ctx, getArgs) 85 | if err == nil && len(*prs) > 0 { 86 | pr := (*prs)[0] 87 | var autoCompleteSetBy *webapi.IdentityRef 88 | if auto { 89 | autoCompleteSetBy = pr.CreatedBy 90 | } 91 | updatePR := git.GitPullRequest{ 92 | Title: &title, 93 | Description: &description, 94 | AutoCompleteSetBy: autoCompleteSetBy, 95 | } 96 | updateArgs := git.UpdatePullRequestArgs{ 97 | Project: &g.proj, 98 | RepositoryId: &g.repo, 99 | PullRequestId: pr.PullRequestId, 100 | GitPullRequestToUpdate: &updatePR, 101 | } 102 | _, err = g.client.UpdatePullRequest(ctx, updateArgs) 103 | if err == nil { 104 | log.Printf("Updated PR #%d merging %s -> %s\n", *pr.PullRequestId, sourceRefName, targetRefName) 105 | } 106 | return *pr.PullRequestId, err 107 | } 108 | 109 | // Create new PR 110 | deleteSourceBranch := true 111 | createArgs := git.CreatePullRequestArgs{ 112 | Project: &g.proj, 113 | RepositoryId: &g.repo, 114 | GitPullRequestToCreate: &git.GitPullRequest{ 115 | Title: &title, 116 | Description: &description, 117 | SourceRefName: &sourceRefName, 118 | TargetRefName: &targetRefName, 119 | CompletionOptions: &git.GitPullRequestCompletionOptions{ 120 | DeleteSourceBranch: &deleteSourceBranch, 121 | }, 122 | }, 123 | } 124 | pr, err := g.client.CreatePullRequest(ctx, createArgs) 125 | if err != nil { 126 | return 0, err 127 | } 128 | log.Printf("Created new PR #%d merging %s -> %s\n", *pr.PullRequestId, sourceRefName, targetRefName) 129 | if !auto { 130 | return *pr.PullRequestId, nil 131 | } 132 | 133 | // This update is done to set auto merge. The reason this is not 134 | // done when creating is because there is no reasonable way to 135 | // get the identity ref other than from the response. 136 | updatePR := git.GitPullRequest{ 137 | AutoCompleteSetBy: pr.CreatedBy, 138 | } 139 | updateArgs := git.UpdatePullRequestArgs{ 140 | Project: &g.proj, 141 | RepositoryId: &g.repo, 142 | PullRequestId: pr.PullRequestId, 143 | GitPullRequestToUpdate: &updatePR, 144 | } 145 | _, err = g.client.UpdatePullRequest(ctx, updateArgs) 146 | if err == nil { 147 | log.Printf("Auto-merge activated for PR #%d\n", *pr.PullRequestId) 148 | } 149 | return *pr.PullRequestId, err 150 | } 151 | 152 | func (g *AzdoGITProvider) GetStatus(ctx context.Context, sha string, group string, env string) (CommitStatus, error) { 153 | args := git.GetStatusesArgs{ 154 | Project: &g.proj, 155 | RepositoryId: &g.repo, 156 | CommitId: &sha, 157 | } 158 | statuses, err := g.client.GetStatuses(ctx, args) 159 | if err != nil { 160 | return CommitStatus{}, err 161 | } 162 | var displays = make([]string, len(*statuses)) 163 | for i := range *statuses { 164 | s := (*statuses)[i] 165 | displays = append(displays, fmt.Sprintf("%s/%s: %s (%s)", *s.Context.Genre, *s.Context.Name, *s.State, *s.Description)) 166 | } 167 | log.Printf("Considering statuses %v\n", displays) 168 | 169 | genre := "fluxcd" 170 | name := fmt.Sprintf("%s-%s", group, env) 171 | for i := range *statuses { 172 | s := (*statuses)[i] 173 | comp := strings.Split(*s.Context.Name, "/") 174 | if len(comp) < 2 { 175 | return CommitStatus{}, fmt.Errorf("status name in wrong format: %q", *s.Context.Name) 176 | } 177 | if *s.Context.Genre == genre && comp[1] == name { 178 | return CommitStatus{ 179 | Succeeded: *s.State == git.GitStatusStateValues.Succeeded, 180 | }, nil 181 | } 182 | } 183 | return CommitStatus{}, fmt.Errorf("no status found for sha %q", sha) 184 | } 185 | 186 | func (g *AzdoGITProvider) SetStatus(ctx context.Context, sha string, group string, env string, succeeded bool) error { 187 | genre := "fluxcd" 188 | description := fmt.Sprintf("%s-%s-%s", group, env, sha) 189 | name := fmt.Sprintf("kind/%s-%s", group, env) 190 | 191 | state := &git.GitStatusStateValues.Succeeded 192 | if !succeeded { 193 | state = &git.GitStatusStateValues.Failed 194 | } 195 | 196 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 197 | defer cancel() 198 | 199 | createArgs := git.CreateCommitStatusArgs{ 200 | Project: &g.proj, 201 | RepositoryId: &g.repo, 202 | CommitId: &sha, 203 | GitCommitStatusToCreate: &git.GitStatus{ 204 | Description: &description, 205 | State: state, 206 | Context: &git.GitStatusContext{ 207 | Genre: &genre, 208 | Name: &name, 209 | }, 210 | }, 211 | } 212 | 213 | _, err := g.client.CreateCommitStatus(ctx, createArgs) 214 | return err 215 | } 216 | 217 | func (g *AzdoGITProvider) MergePR(ctx context.Context, id int, sha string) error { 218 | args := git.UpdatePullRequestArgs{ 219 | Project: &g.proj, 220 | RepositoryId: &g.repo, 221 | PullRequestId: &id, 222 | GitPullRequestToUpdate: &git.GitPullRequest{ 223 | Status: &git.PullRequestStatusValues.Completed, 224 | LastMergeSourceCommit: &git.GitCommitRef{ 225 | CommitId: &sha, 226 | }, 227 | }, 228 | } 229 | _, err := g.client.UpdatePullRequest(ctx, args) 230 | return err 231 | } 232 | 233 | func (g *AzdoGITProvider) GetPRWithBranch(ctx context.Context, source, target string) (PullRequest, error) { 234 | sourceRefName := fmt.Sprintf("refs/heads/%s", source) 235 | targetRefName := fmt.Sprintf("refs/heads/%s", target) 236 | args := git.GetPullRequestsArgs{ 237 | Project: &g.proj, 238 | RepositoryId: &g.repo, 239 | SearchCriteria: &git.GitPullRequestSearchCriteria{ 240 | SourceRefName: &sourceRefName, 241 | TargetRefName: &targetRefName, 242 | }, 243 | } 244 | prs, err := g.client.GetPullRequests(ctx, args) 245 | if err != nil { 246 | return PullRequest{}, err 247 | } 248 | if len(*prs) == 0 { 249 | return PullRequest{}, fmt.Errorf("no PR found for branches %q-%q", source, target) 250 | } 251 | 252 | pr := (*prs)[0] 253 | 254 | result, err := NewPullRequest(pr.PullRequestId, pr.Title, pr.Description) 255 | if err != nil { 256 | return PullRequest{}, err 257 | } 258 | 259 | return result, nil 260 | } 261 | 262 | func (g *AzdoGITProvider) GetPRThatCausedCommit(ctx context.Context, sha string) (PullRequest, error) { 263 | args := git.GetPullRequestQueryArgs{ 264 | Project: &g.proj, 265 | RepositoryId: &g.repo, 266 | Queries: &git.GitPullRequestQuery{ 267 | Queries: &[]git.GitPullRequestQueryInput{ 268 | { 269 | Items: &[]string{sha}, 270 | Type: &git.GitPullRequestQueryTypeValues.LastMergeCommit, 271 | }, 272 | }, 273 | }, 274 | } 275 | query, err := g.client.GetPullRequestQuery(ctx, args) 276 | if err != nil { 277 | return PullRequest{}, err 278 | } 279 | results := *query.Results 280 | if len(results[0]) == 0 { 281 | return PullRequest{}, fmt.Errorf("no PR found for commit %q", sha) 282 | } 283 | pr := results[0][sha][0] 284 | 285 | result, err := NewPullRequest(pr.PullRequestId, pr.Title, pr.Description) 286 | if err != nil { 287 | return PullRequest{}, err 288 | } 289 | 290 | return result, nil 291 | } 292 | -------------------------------------------------------------------------------- /pkg/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "path/filepath" 8 | "time" 9 | 10 | git2go "github.com/libgit2/git2go/v33" 11 | ) 12 | 13 | const ( 14 | DefaultUsername = "git" 15 | DefaultRemote = "origin" 16 | DefaultBranch = "main" 17 | ) 18 | 19 | type CommitStatus struct { 20 | Succeeded bool 21 | } 22 | 23 | // Repository represents a local git repository. 24 | type Repository struct { 25 | gitRepository *git2go.Repository 26 | gitProvider GitProvider 27 | token string 28 | } 29 | 30 | // LoadRepository loads a local git repository. 31 | func LoadRepository(ctx context.Context, path string, providerTypeString string, token string) (*Repository, error) { 32 | localRepo, err := git2go.OpenRepository(path) 33 | if err != nil { 34 | return &Repository{}, fmt.Errorf("could not open repository: %w", err) 35 | } 36 | remote, err := localRepo.Remotes.Lookup(DefaultRemote) 37 | if err != nil { 38 | return nil, fmt.Errorf("could not get remote: %w", err) 39 | } 40 | provider, err := NewGitProvider(ctx, ProviderType(providerTypeString), remote.Url(), token) 41 | if err != nil { 42 | return nil, fmt.Errorf("could not create git provider: %w", err) 43 | } 44 | return &Repository{ 45 | gitRepository: localRepo, 46 | gitProvider: provider, 47 | token: token, 48 | }, nil 49 | } 50 | 51 | // FetchDefaultBranch updates DefaultBranch with new commits from DefaultRemote. 52 | func (g *Repository) FetchBranch(branchName string) (*git2go.Oid, error) { 53 | remote, err := g.gitRepository.Remotes.Lookup(DefaultRemote) 54 | if err != nil { 55 | return nil, fmt.Errorf("could not find remote %q: %w", DefaultRemote, err) 56 | } 57 | err = remote.Fetch( 58 | []string{branchName}, 59 | &git2go.FetchOptions{ 60 | RemoteCallbacks: credentialsCallback(DefaultUsername, g.token), 61 | }, 62 | "", 63 | ) 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to fetch: %w", err) 66 | } 67 | sha, err := g.GetLastCommitForBranch(fmt.Sprintf("%s/%s", DefaultRemote, DefaultBranch)) 68 | if err != nil { 69 | return nil, fmt.Errorf("fetch failed to lookup head sha: %w", err) 70 | } 71 | return sha, nil 72 | } 73 | 74 | // GetRootDir returns the file path to the repository. 75 | func (g *Repository) GetRootDir() string { 76 | p := g.gitRepository.Path() 77 | rp := filepath.Clean(filepath.Join(p, "..")) 78 | return rp 79 | } 80 | 81 | // CreateBranch creates a branch. 82 | func (g *Repository) CreateBranch(branchName string, force bool) error { 83 | branch, err := g.gitRepository.LookupBranch(branchName, git2go.BranchLocal) 84 | if err == nil { 85 | err = branch.Delete() 86 | if err != nil { 87 | return fmt.Errorf("could not delete existing branch %q: %w", branchName, err) 88 | } 89 | } 90 | head, err := g.gitRepository.Head() 91 | if err != nil { 92 | return err 93 | } 94 | headCommit, err := g.gitRepository.LookupCommit(head.Target()) 95 | if err != nil { 96 | return err 97 | } 98 | _, err = g.gitRepository.CreateBranch(branchName, headCommit, force) 99 | if err != nil { 100 | return err 101 | } 102 | return nil 103 | } 104 | 105 | // GetLastCommitForBranch returns the latest commit id for the branch. 106 | func (g *Repository) GetLastCommitForBranch(branchName string) (*git2go.Oid, error) { 107 | branch, err := g.gitRepository.LookupBranch(branchName, git2go.BranchRemote) 108 | if err != nil { 109 | return nil, err 110 | } 111 | return branch.Target(), nil 112 | } 113 | 114 | // GetCurrentCommit returns the commmit id for HEAD. 115 | func (g *Repository) GetCurrentCommit() (*git2go.Oid, error) { 116 | head, err := g.gitRepository.Head() 117 | if err != nil { 118 | return nil, err 119 | } 120 | return head.Target(), nil 121 | } 122 | 123 | // CreateCommit creates a commit in the specfied branch with the current changes. 124 | func (g *Repository) CreateCommit(branchName, message string) (*git2go.Oid, error) { 125 | // TODO change to some bot name, probably break out in to config 126 | signature := &git2go.Signature{ 127 | Name: "gitops-promotion", 128 | Email: "gitops-promotion@xenit.se", 129 | When: time.Now(), 130 | } 131 | idx, err := g.gitRepository.Index() 132 | if err != nil { 133 | return nil, err 134 | } 135 | err = idx.AddAll([]string{}, git2go.IndexAddDefault, nil) 136 | if err != nil { 137 | return nil, err 138 | } 139 | treeId, err := idx.WriteTree() 140 | if err != nil { 141 | return nil, err 142 | } 143 | err = idx.Write() 144 | if err != nil { 145 | return nil, err 146 | } 147 | tree, err := g.gitRepository.LookupTree(treeId) 148 | if err != nil { 149 | return nil, err 150 | } 151 | branch, err := g.gitRepository.LookupBranch(branchName, git2go.BranchLocal) 152 | if err != nil { 153 | return nil, err 154 | } 155 | commitTarget, err := g.gitRepository.LookupCommit(branch.Target()) 156 | if err != nil { 157 | return nil, err 158 | } 159 | refName := fmt.Sprintf("refs/heads/%s", branchName) 160 | sha, err := g.gitRepository.CreateCommit(refName, signature, signature, message, tree, commitTarget) 161 | if err != nil { 162 | return nil, err 163 | } 164 | log.Printf("Created commit %s on %s with message '%s'\n", sha, refName, message) 165 | return sha, nil 166 | } 167 | 168 | // GetLastCommitForPath returns the last commit for the given path. All files and subdirectories 169 | // will be considered if the path is a directory. 170 | // 171 | //nolint:gocognit // ignore 172 | func (g *Repository) GetLastCommitForPath(path string) (*git2go.Commit, error) { 173 | // There is currently no implementation in libgit2 to recursively check files. 174 | // Here is an issue in git2go which discusses the problem of recursively checking files. 175 | // https://github.com/libgit2/git2go/issues/729 176 | // This implementation should probably be refactored in the future. 177 | 178 | // Get head commit 179 | head, err := g.gitRepository.Head() 180 | if err != nil { 181 | return nil, err 182 | } 183 | commit, err := g.gitRepository.LookupCommit(head.Target()) 184 | if err != nil { 185 | return nil, err 186 | } 187 | 188 | walk, err := g.gitRepository.Walk() 189 | if err != nil { 190 | return nil, err 191 | } 192 | // Set commit to start at 193 | err = walk.Push(commit.Id()) 194 | if err != nil { 195 | return nil, err 196 | } 197 | // Iterate through all commits 198 | var lastCommit *git2go.Commit 199 | err = walk.Iterate(func(commit *git2go.Commit) bool { 200 | if commit.ParentCount() == 0 { 201 | return true 202 | } 203 | parent := commit.Parent(0) 204 | commitTree, err := commit.Tree() 205 | if err != nil { 206 | fmt.Println("commit tree", err) 207 | return true 208 | } 209 | parentTree, err := parent.Tree() 210 | if err != nil { 211 | return true 212 | } 213 | cId, err := commitTree.EntryByPath(path) 214 | if err != nil { 215 | return true 216 | } 217 | pId, err := parentTree.EntryByPath(path) 218 | if err != nil { 219 | // Assume that the path only has a single commit 220 | // as there is no parent with the same path entry. 221 | lastCommit = commit 222 | return false 223 | } 224 | if cId.Id.String() != pId.Id.String() { 225 | lastCommit = commit 226 | return false 227 | } 228 | return true 229 | }) 230 | if err != nil { 231 | return nil, err 232 | } 233 | if lastCommit == nil { 234 | return nil, fmt.Errorf("commit not found for path %s", path) 235 | } 236 | 237 | return lastCommit, nil 238 | } 239 | 240 | // Push pushes the given branch to the remote. 241 | func (g *Repository) Push(branchName string, force bool) error { 242 | remote, err := g.gitRepository.Remotes.Lookup(DefaultRemote) 243 | if err != nil { 244 | return fmt.Errorf("could not find remote %q: %w", DefaultRemote, err) 245 | } 246 | 247 | forceFlag := "+" 248 | if !force { 249 | forceFlag = "" 250 | } 251 | 252 | branches := []string{fmt.Sprintf("%srefs/heads/%s", forceFlag, branchName)} 253 | err = remote.Push(branches, &git2go.PushOptions{RemoteCallbacks: credentialsCallback(DefaultUsername, g.token)}) 254 | if err != nil { 255 | return fmt.Errorf("failed pushing branches %s: %w", branches, err) 256 | } 257 | log.Printf("Pushed branch %s to remote\n", branches[0]) 258 | return nil 259 | } 260 | 261 | // CreatePR creates a PR for the branch. It assumes that the branch has been pushed. 262 | func (g *Repository) CreatePR(ctx context.Context, branchName string, auto bool, title, description string) (int, error) { 263 | return g.gitProvider.CreatePR(ctx, branchName, auto, title, description) 264 | } 265 | 266 | // GetStatus returns the status for the give commit. 267 | func (g *Repository) GetStatus(ctx context.Context, sha, group, env string) (CommitStatus, error) { 268 | return g.gitProvider.GetStatus(ctx, sha, group, env) 269 | } 270 | 271 | // MergePR merges the given PR. 272 | func (g *Repository) MergePR(ctx context.Context, id int, sha string) error { 273 | return g.gitProvider.MergePR(ctx, id, sha) 274 | } 275 | 276 | // GetBranchName returns the branch name of for HEAD. 277 | func (g *Repository) GetBranchName() (string, error) { 278 | head, err := g.gitRepository.Head() 279 | if err != nil { 280 | return "", err 281 | } 282 | branchName, err := head.Branch().Name() 283 | if err != nil { 284 | return "", err 285 | } 286 | return branchName, nil 287 | } 288 | 289 | // GetPRForCurrentBranch returns any active PR for the current branch. 290 | func (g *Repository) GetPRForCurrentBranch(ctx context.Context) (PullRequest, error) { 291 | branchName, err := g.GetBranchName() 292 | if err != nil { 293 | return PullRequest{}, err 294 | } 295 | pr, err := g.gitProvider.GetPRWithBranch(ctx, branchName, DefaultBranch) 296 | if err != nil { 297 | return PullRequest{}, err 298 | } 299 | return pr, nil 300 | } 301 | 302 | // GetPRThatCausedCurrentCommit finds the merged PR with resulted in the current commit. 303 | func (g *Repository) GetPRThatCausedCurrentCommit(ctx context.Context) (PullRequest, error) { 304 | head, err := g.gitRepository.Head() 305 | if err != nil { 306 | return PullRequest{}, err 307 | } 308 | pr, err := g.gitProvider.GetPRThatCausedCommit(ctx, head.Target().String()) 309 | if err != nil { 310 | return PullRequest{}, err 311 | } 312 | return pr, err 313 | } 314 | 315 | func Clone(url, username, password, path, branchName string) error { 316 | _, err := git2go.Clone(url, path, &git2go.CloneOptions{ 317 | FetchOptions: git2go.FetchOptions{ 318 | DownloadTags: git2go.DownloadTagsNone, 319 | RemoteCallbacks: credentialsCallback(username, password), 320 | }, 321 | CheckoutBranch: branchName, 322 | }) 323 | return err 324 | } 325 | 326 | func credentialsCallback(username, password string) git2go.RemoteCallbacks { 327 | return git2go.RemoteCallbacks{ 328 | CredentialsCallback: func(url string, usernameFromURL string, allowedTypes git2go.CredentialType) (*git2go.Credential, error) { 329 | cred, err := git2go.NewCredentialUserpassPlaintext(username, password) 330 | if err != nil { 331 | return nil, err 332 | } 333 | return cred, nil 334 | }, 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /pkg/git/github.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/avast/retry-go" 14 | "github.com/google/go-github/v45/github" 15 | "github.com/shurcooL/githubv4" 16 | "golang.org/x/oauth2" 17 | ) 18 | 19 | // GitHubGITProvider ... 20 | type GitHubGITProvider struct { 21 | authClient *http.Client 22 | client *github.Client 23 | owner string 24 | repo string 25 | } 26 | 27 | // NewGitHubGITProvider ... 28 | func NewGitHubGITProvider(ctx context.Context, remoteURL, token string) (*GitHubGITProvider, error) { 29 | if remoteURL == "" { 30 | return nil, fmt.Errorf("remoteURL empty") 31 | } 32 | if token == "" { 33 | return nil, fmt.Errorf("token empty") 34 | } 35 | 36 | host, id, err := ParseGitAddress(remoteURL) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if host != "https://github.com" { 41 | return nil, fmt.Errorf("host does not start with https://github.com: %s", host) 42 | } 43 | 44 | comp := strings.Split(id, "/") 45 | if len(comp) != 2 { 46 | return nil, fmt.Errorf("invalid repository id %q", id) 47 | } 48 | owner := comp[0] 49 | repo := comp[1] 50 | 51 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 52 | tc := oauth2.NewClient(ctx, ts) 53 | client := github.NewClient(tc) 54 | 55 | return &GitHubGITProvider{ 56 | authClient: tc, 57 | client: client, 58 | owner: owner, 59 | repo: repo, 60 | }, nil 61 | } 62 | 63 | // CreatePR ... 64 | // 65 | //nolint:gocognit //temporary 66 | func (g *GitHubGITProvider) CreatePR(ctx context.Context, branchName string, auto bool, title, description string) (int, error) { 67 | sourceName := branchName 68 | targetName := DefaultBranch 69 | 70 | listOpts := &github.PullRequestListOptions{ 71 | State: "open", 72 | Base: targetName, 73 | } 74 | 75 | openPrs, _, err := g.client.PullRequests.List(ctx, g.owner, g.repo, listOpts) 76 | if err != nil { 77 | return 0, err 78 | } 79 | 80 | var prsOnBranch []*github.PullRequest 81 | for _, pr := range openPrs { 82 | if sourceName == *pr.Head.Ref { 83 | prsOnBranch = append(prsOnBranch, pr) 84 | } 85 | } 86 | 87 | var pr *github.PullRequest 88 | switch len(prsOnBranch) { 89 | case 0: 90 | createOpts := &github.NewPullRequest{ 91 | Title: &title, 92 | Body: &description, 93 | Head: &sourceName, 94 | Base: &targetName, 95 | MaintainerCanModify: github.Bool(true), 96 | } 97 | pr, _, err = g.client.PullRequests.Create(ctx, g.owner, g.repo, createOpts) 98 | if err == nil { 99 | log.Printf("Created new PR #%d merging %s -> %s\n", pr.GetNumber(), sourceName, targetName) 100 | } 101 | case 1: 102 | pr = (prsOnBranch)[0] 103 | pr.Title = &title 104 | pr.Body = &description 105 | pr.Base.Ref = &targetName 106 | pr, _, err = g.client.PullRequests.Edit(ctx, g.owner, g.repo, *pr.Number, pr) 107 | if err == nil { 108 | log.Printf("Updated PR #%d merging %s -> %s\n", pr.GetNumber(), sourceName, targetName) 109 | } 110 | default: 111 | return 0, fmt.Errorf("received more than one PRs when listing: %d", len(prsOnBranch)) 112 | } 113 | 114 | if err != nil { 115 | return 0, err 116 | } 117 | 118 | if auto != (pr.GetAutoMerge() != nil) { 119 | client := githubv4.NewClient(g.authClient) 120 | var mutation struct { 121 | EnablePullRequestAutoMerge struct { 122 | PullRequest struct { 123 | ID githubv4.ID 124 | } 125 | } `graphql:"enablePullRequestAutoMerge(input: $input)"` 126 | } 127 | input := githubv4.EnablePullRequestAutoMergeInput{ 128 | PullRequestID: pr.GetNodeID(), 129 | } 130 | err = client.Mutate(ctx, &mutation, input, nil) 131 | if err == nil { 132 | log.Printf("Auto-merge activated for PR #%d\n", pr.GetNumber()) 133 | } else { 134 | log.Printf("Failed to activate auto-merge for PR %d: %v", pr.GetNumber(), err) 135 | if strings.Contains(err.Error(), "Can't enable auto-merge") { 136 | err = fmt.Errorf("could not set auto-merge on PR #%d (check auto-merge setting and required checks)", pr.GetNumber()) 137 | } 138 | } 139 | } 140 | return pr.GetNumber(), err 141 | } 142 | 143 | func (g *GitHubGITProvider) GetStatus(ctx context.Context, sha string, group string, env string) (CommitStatus, error) { 144 | opts := &github.ListOptions{PerPage: 50} 145 | statuses, _, err := g.client.Repositories.ListStatuses(ctx, g.owner, g.repo, sha, opts) 146 | if err != nil { 147 | return CommitStatus{}, err 148 | } 149 | var displays = make([]string, len(statuses)) 150 | for i := range statuses { 151 | s := statuses[i] 152 | displays = append(displays, fmt.Sprintf("%s: %s (%s)", *s.Context, *s.State, *s.Description)) 153 | } 154 | log.Printf("Considering statuses %v\n", displays) 155 | 156 | name := fmt.Sprintf("%s-%s", group, env) 157 | for _, s := range statuses { 158 | comp := strings.Split(*s.Context, "/") 159 | if len(comp) < 2 { 160 | return CommitStatus{}, fmt.Errorf("status context in wrong format: %q", *s.Context) 161 | } 162 | if comp[1] == name { 163 | return CommitStatus{ 164 | Succeeded: *s.State == "success", 165 | }, nil 166 | } 167 | } 168 | return CommitStatus{}, fmt.Errorf("no status found for sha %q", sha) 169 | } 170 | 171 | func (g *GitHubGITProvider) SetStatus(ctx context.Context, sha string, group string, env string, succeeded bool) error { 172 | description := fmt.Sprintf("%s-%s-%s", group, env, sha) 173 | name := fmt.Sprintf("kind/%s-%s", group, env) 174 | 175 | state := "success" 176 | if !succeeded { 177 | state = "failure" 178 | } 179 | 180 | status := &github.RepoStatus{ 181 | State: &state, 182 | Context: &name, 183 | Description: &description, 184 | } 185 | 186 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 187 | defer cancel() 188 | 189 | _, _, err := g.client.Repositories.CreateStatus(ctx, g.owner, g.repo, sha, status) 190 | return err 191 | } 192 | 193 | func (g *GitHubGITProvider) MergePR(ctx context.Context, id int, sha string) error { 194 | opts := &github.PullRequestOptions{ 195 | SHA: sha, 196 | } 197 | 198 | var result *github.PullRequestMergeResult 199 | var res *github.Response 200 | err := retry.Do( 201 | func() error { 202 | var err error 203 | result, res, err = g.client.PullRequests.Merge(ctx, g.owner, g.repo, id, "", opts) 204 | if err != nil && res.StatusCode == 405 { 205 | updateOpts := &github.PullRequestBranchUpdateOptions{} 206 | _, _, innerErr := g.client.PullRequests.UpdateBranch(ctx, g.owner, g.repo, id, updateOpts) 207 | if innerErr != nil { 208 | return innerErr 209 | } 210 | } 211 | return err 212 | }, 213 | retry.Attempts(5), 214 | retry.LastErrorOnly(true), 215 | ) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | mergeSucceeded := *result.Merged 221 | 222 | if !mergeSucceeded { 223 | body, err := ioutil.ReadAll(res.Response.Body) 224 | if err != nil { 225 | return err 226 | } 227 | 228 | defer func() { 229 | err := res.Response.Body.Close() 230 | if err != nil { 231 | fmt.Fprintf(os.Stderr, "MergePR - unable to close body: %v", err) 232 | } 233 | }() 234 | 235 | return fmt.Errorf("PR with ID %d was not merged: %s", id, body) 236 | } 237 | 238 | return nil 239 | } 240 | 241 | func (g *GitHubGITProvider) GetPRWithBranch(ctx context.Context, source, target string) (PullRequest, error) { 242 | listOpts := &github.PullRequestListOptions{ 243 | State: "open", 244 | Base: target, 245 | } 246 | 247 | var prs []*github.PullRequest 248 | err := retry.Do( 249 | func() error { 250 | openPrs, _, err := g.client.PullRequests.List(ctx, g.owner, g.repo, listOpts) 251 | if err != nil { 252 | return err 253 | } 254 | for _, pr := range openPrs { 255 | if source == *pr.Head.Ref { 256 | prs = append(prs, pr) 257 | } 258 | } 259 | if len(prs) != 1 { 260 | return fmt.Errorf("no PR found for branches %q-%q", source, target) 261 | } 262 | return nil 263 | }, 264 | retry.Attempts(5), 265 | retry.LastErrorOnly(true), 266 | ) 267 | if err != nil { 268 | return PullRequest{}, err 269 | } 270 | 271 | pr := prs[0] 272 | 273 | return NewPullRequest(pr.Number, pr.Title, pr.Body) 274 | } 275 | 276 | //nolint:gocognit // ignore 277 | func (g *GitHubGITProvider) GetPRThatCausedCommit(ctx context.Context, sha string) (PullRequest, error) { 278 | listOpts := &github.PullRequestListOptions{ 279 | State: "closed", 280 | } 281 | 282 | var prs []*github.PullRequest 283 | err := retry.Do( 284 | func() error { 285 | closedPrs, _, err := g.client.PullRequests.List(ctx, g.owner, g.repo, listOpts) 286 | if err != nil { 287 | return err 288 | } 289 | for _, pr := range closedPrs { 290 | if pr == nil { 291 | continue 292 | } 293 | // The SHA will be nil if the PR is closed without being merged 294 | if pr.MergeCommitSHA == nil { 295 | continue 296 | } 297 | if sha == *pr.MergeCommitSHA { 298 | prs = append(prs, pr) 299 | } 300 | } 301 | if len(prs) != 1 { 302 | return fmt.Errorf("no PR found for sha: %s", sha) 303 | } 304 | return nil 305 | }, 306 | retry.Attempts(5), 307 | retry.LastErrorOnly(true), 308 | ) 309 | if err != nil { 310 | return PullRequest{}, err 311 | } 312 | pr := prs[0] 313 | 314 | return NewPullRequest(pr.Number, pr.Title, pr.Body) 315 | } 316 | -------------------------------------------------------------------------------- /pkg/git/github_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/google/go-github/v45/github" 15 | "github.com/google/uuid" 16 | git2go "github.com/libgit2/git2go/v33" 17 | . "github.com/onsi/ginkgo" 18 | . "github.com/onsi/gomega" 19 | ) 20 | 21 | var remoteURL string = os.Getenv("GITHUB_URL") 22 | var token string = os.Getenv("GITHUB_TOKEN") 23 | var createdRepos []string 24 | 25 | func randomBranchName(prefix string) string { 26 | return prefix + "-" + uuid.NewString() 27 | } 28 | 29 | func cloneTestRepoOnExistingBranch(ctx context.Context, branchName string) *Repository { 30 | providerTypeString := string(ProviderTypeGitHub) 31 | tmpDir, e := ioutil.TempDir("", "gitops-promotion") 32 | createdRepos = append(createdRepos, tmpDir) 33 | Expect(e).To(BeNil()) 34 | e = Clone(remoteURL, "pat", token, tmpDir, branchName) 35 | Expect(e).To(BeNil()) 36 | repo, e := LoadRepository(ctx, tmpDir, providerTypeString, token) 37 | Expect(e).To(BeNil()) 38 | return repo 39 | } 40 | 41 | func cloneTestRepoWithNewBranch(ctx context.Context, branchName string) *Repository { 42 | repo := cloneTestRepoOnExistingBranch(ctx, DefaultBranch) 43 | e := repo.CreateBranch(branchName, false) 44 | Expect(e).To(BeNil()) 45 | return repo 46 | } 47 | 48 | func pushBranch(repo *Repository, branchName string) { 49 | e := repo.Push(branchName, true) 50 | Expect(e).To(BeNil()) 51 | } 52 | 53 | func commitAFile(repo *Repository, branchName string) *git2go.Oid { 54 | fileName := fmt.Sprintf( 55 | "%s/%s.txt", 56 | filepath.Dir(strings.TrimRight(repo.gitRepository.Path(), "/")), 57 | branchName, 58 | ) 59 | f, e := os.Create(fileName) 60 | Expect(e).To(BeNil()) 61 | _, e = f.WriteString(fmt.Sprintln(time.Now())) 62 | Expect(e).To(BeNil()) 63 | e = f.Close() 64 | Expect(e).To(BeNil()) 65 | sha, e := repo.CreateCommit(branchName, fmt.Sprintln(time.Now())) 66 | Expect(e).To(BeNil()) 67 | return sha 68 | } 69 | 70 | var _ = AfterSuite(func() { 71 | for _, path := range createdRepos { 72 | e := os.RemoveAll(path) 73 | Expect(e).To(BeNil()) 74 | } 75 | }) 76 | 77 | func TestNewGitHubGITProvider(t *testing.T) { 78 | RegisterFailHandler(Fail) 79 | RunSpecs(t, "GitHubProvider") 80 | } 81 | 82 | var _ = Describe("NewGitHubGITProvider", func() { 83 | var err error 84 | var ctx context.Context 85 | 86 | BeforeEach(func() { 87 | err = nil 88 | ctx = context.Background() 89 | }) 90 | 91 | It("returns error when creating without url", func() { 92 | _, err = NewGitHubGITProvider(ctx, "", "foo") 93 | Expect(err).To(MatchError("remoteURL empty")) 94 | }) 95 | 96 | It("returns error when creating without token", func() { 97 | _, err = NewGitHubGITProvider(ctx, "https://github.com/org/repo", "") 98 | Expect(err).To(MatchError("token empty")) 99 | }) 100 | 101 | It("returns error when creating without github address", func() { 102 | _, err = NewGitHubGITProvider(ctx, "https://foo.bar/org/repo", "foo") 103 | Expect(err).To(MatchError("host does not start with https://github.com: https://foo.bar")) 104 | }) 105 | 106 | It("is successfully created when creating with correct token", func() { 107 | var provider *GitHubGITProvider 108 | remoteURL := os.Getenv("GITHUB_URL") 109 | token := os.Getenv("GITHUB_TOKEN") 110 | 111 | if remoteURL == "" || token == "" { 112 | Skip("GITHUB_URL and/or GITHUB_TOKEN environment variables not set") 113 | } 114 | 115 | provider, err = NewGitHubGITProvider(ctx, remoteURL, token) 116 | Expect(err).To(BeNil()) 117 | Expect(remoteURL).To(ContainSubstring(provider.owner)) 118 | Expect(remoteURL).To(ContainSubstring(provider.repo)) 119 | }) 120 | }) 121 | 122 | var _ = Describe("GitHubGITProvider CreatePR", func() { 123 | ctx := context.Background() 124 | provider, providerErr := NewGitHubGITProvider(ctx, remoteURL, token) 125 | 126 | BeforeEach(func() { 127 | if remoteURL == "" || token == "" { 128 | Skip("GITHUB_URL and/or GITHUB_TOKEN environment variables not set") 129 | } 130 | 131 | if providerErr != nil { 132 | Fail(fmt.Sprintf("Provider initialization failed: %s", providerErr)) 133 | } 134 | }) 135 | 136 | var prid int 137 | var err error 138 | var branchName string 139 | var auto bool 140 | state := &PRState{ 141 | Env: "dev", 142 | Group: "testgroup", 143 | App: "testapp", 144 | Tag: time.Now().Format("20060102150405"), 145 | Sha: "", 146 | } 147 | 148 | JustBeforeEach(func() { 149 | title := state.Title() 150 | fmt.Println("title:", title) 151 | description, err := state.Description() 152 | Expect(err).To(BeNil()) 153 | //nolint:ineffassign,staticcheck // ignore 154 | prid, err = provider.CreatePR(ctx, branchName, auto, title, description) 155 | }) 156 | 157 | When("Creating PR with empty values", func() { 158 | It("returns error", func() { 159 | var gitHubError *github.ErrorResponse 160 | fmt.Println("foobar", err) 161 | ok := errors.As(err, &gitHubError) 162 | Expect(ok).To(Equal(true)) 163 | body, bodyErr := ioutil.ReadAll(gitHubError.Response.Body) 164 | Expect(bodyErr).To(BeNil()) 165 | bodyErr = gitHubError.Response.Body.Close() 166 | Expect(bodyErr).To(BeNil()) 167 | 168 | Expect(string(body)).To(ContainSubstring("{\"resource\":\"PullRequest\",\"code\":\"missing_field\",\"field\":\"head\"}")) 169 | }) 170 | }) 171 | 172 | When("Creating PR with non-existing branchName", func() { 173 | BeforeEach(func() { 174 | branchName = randomBranchName("testing-does-not-exist") 175 | }) 176 | 177 | It("returns error", func() { 178 | var gitHubError *github.ErrorResponse 179 | ok := errors.As(err, &gitHubError) 180 | Expect(ok).To(Equal(true)) 181 | body, bodyErr := ioutil.ReadAll(gitHubError.Response.Body) 182 | Expect(bodyErr).To(BeNil()) 183 | bodyErr = gitHubError.Response.Body.Close() 184 | Expect(bodyErr).To(BeNil()) 185 | 186 | Expect(string(body)).To(ContainSubstring("{\"resource\":\"PullRequest\",\"field\":\"head\",\"code\":\"invalid\"}")) 187 | }) 188 | }) 189 | 190 | When("Creating PR on a new branch", func() { 191 | var repo *Repository 192 | 193 | BeforeEach(func() { 194 | branchName = randomBranchName("empty-branch") 195 | repo = cloneTestRepoWithNewBranch(ctx, branchName) 196 | commitAFile(repo, branchName) 197 | pushBranch(repo, branchName) 198 | }) 199 | 200 | It("doesn't return an error", func() { 201 | Expect(err).To(BeNil()) 202 | }) 203 | 204 | It("returns the PR number", func() { 205 | Expect(prid).To(BeNumerically(">", 0)) 206 | }) 207 | }) 208 | 209 | When("Creating PR on a branch that already has a PR", func() { 210 | var origPRId int 211 | var repo *Repository 212 | 213 | BeforeEach(func() { 214 | var e error 215 | branchName = randomBranchName("testing-create-pr") 216 | repo = cloneTestRepoWithNewBranch(ctx, branchName) 217 | commitAFile(repo, branchName) 218 | pushBranch(repo, branchName) 219 | 220 | title := state.Title() 221 | description, err := state.Description() 222 | Expect(err).To(BeNil()) 223 | origPRId, e = provider.CreatePR(ctx, branchName, false, title, description) 224 | Expect(e).To(BeNil()) 225 | }) 226 | 227 | It("doesn't return an error", func() { 228 | Expect(err).To(BeNil()) 229 | }) 230 | 231 | It("returns the original PRs id", func() { 232 | Expect(prid).To(Equal(origPRId)) 233 | }) 234 | }) 235 | 236 | When("Creating/updating a PR with automerge", func() { 237 | var repo *Repository 238 | 239 | BeforeEach(func() { 240 | auto = true 241 | branchName = randomBranchName("with-automerge") 242 | repo = cloneTestRepoWithNewBranch(ctx, branchName) 243 | commitAFile(repo, branchName) 244 | pushBranch(repo, branchName) 245 | }) 246 | 247 | It("returns an error saying auto-merge is not turned on", func() { 248 | Expect(err.Error()).To(ContainSubstring("could not set auto-merge")) 249 | }) 250 | 251 | When("and the repository has branch protection requiring passing statuses", func() { 252 | BeforeEach(func() { 253 | _, _, err := provider.client.Repositories.UpdateBranchProtection( 254 | ctx, 255 | provider.owner, 256 | provider.repo, 257 | DefaultBranch, 258 | &github.ProtectionRequest{ 259 | EnforceAdmins: true, 260 | RequiredStatusChecks: &github.RequiredStatusChecks{ 261 | Strict: true, 262 | Contexts: []string{}, 263 | }, 264 | }) 265 | Expect(err).To(BeNil()) 266 | }) 267 | 268 | AfterEach(func() { 269 | _, _, err := provider.client.Repositories.UpdateBranchProtection( 270 | ctx, 271 | provider.owner, 272 | provider.repo, 273 | DefaultBranch, 274 | &github.ProtectionRequest{}) 275 | Expect(err).To(BeNil()) 276 | }) 277 | 278 | It("doesn't return an error", func() { 279 | Expect(err).To(BeNil()) 280 | }) 281 | }) 282 | }) 283 | }) 284 | 285 | var _ = Describe("GitHubGITProvider GetStatus", func() { 286 | ctx := context.Background() 287 | provider, providerErr := NewGitHubGITProvider(ctx, remoteURL, token) 288 | 289 | BeforeEach(func() { 290 | if remoteURL == "" || token == "" { 291 | Skip("GITHUB_URL and/or GITHUB_TOKEN environment variables not set") 292 | } 293 | 294 | if providerErr != nil { 295 | Fail(fmt.Sprintf("Provider initialization failed: %s", providerErr)) 296 | } 297 | }) 298 | 299 | var err error 300 | var status CommitStatus 301 | state := &PRState{ 302 | Env: "dev", 303 | Group: "testgroup", 304 | App: "testapp", 305 | Tag: time.Now().Format("20060102150405"), 306 | Sha: "", 307 | } 308 | 309 | JustBeforeEach(func() { 310 | status, err = provider.GetStatus(ctx, state.Sha, state.Group, state.Env) 311 | }) 312 | 313 | When("Getting status of empty sha", func() { 314 | It("return error", func() { 315 | var gitHubError *github.ErrorResponse 316 | ok := errors.As(err, &gitHubError) 317 | Expect(ok).To(Equal(true)) 318 | body, bodyErr := ioutil.ReadAll(gitHubError.Response.Body) 319 | Expect(bodyErr).To(BeNil()) 320 | bodyErr = gitHubError.Response.Body.Close() 321 | Expect(bodyErr).To(BeNil()) 322 | 323 | Expect(string(body)).To(ContainSubstring("\"message\":\"Not Found\"")) 324 | Expect(status.Succeeded).To(Equal(false)) 325 | }) 326 | }) 327 | 328 | When("Getting status of existing sha without status", func() { 329 | var repo *Repository 330 | 331 | BeforeEach(func() { 332 | var e error 333 | repo = cloneTestRepoWithNewBranch(ctx, "ignored") 334 | commitAFile(repo, DefaultBranch) 335 | pushBranch(repo, DefaultBranch) 336 | 337 | sha, e := repo.GetCurrentCommit() 338 | Expect(e).To(BeNil()) 339 | state.Sha = sha.String() 340 | }) 341 | 342 | It("returns an error", func() { 343 | Expect(err.Error()).To(ContainSubstring("no status found for sha")) 344 | Expect(status.Succeeded).To(Equal(false)) 345 | }) 346 | 347 | When("and when a status is set to failure", func() { 348 | BeforeEach(func() { 349 | e := provider.SetStatus(ctx, state.Sha, state.Group, state.Env, false) 350 | Expect(e).To(BeNil()) 351 | }) 352 | 353 | It("reports failure", func() { 354 | Expect(err).To(BeNil()) 355 | Expect(status.Succeeded).To(Equal(false)) 356 | }) 357 | }) 358 | 359 | When("and when a status is set to success", func() { 360 | BeforeEach(func() { 361 | e := provider.SetStatus(ctx, state.Sha, state.Group, state.Env, true) 362 | Expect(e).To(BeNil()) 363 | }) 364 | 365 | It("reports succeeds", func() { 366 | Expect(err).To(BeNil()) 367 | Expect(status.Succeeded).To(Equal(true)) 368 | }) 369 | }) 370 | }) 371 | }) 372 | 373 | var _ = Describe("GitHubGITProvider MergePR", func() { 374 | ctx := context.Background() 375 | provider, providerErr := NewGitHubGITProvider(ctx, remoteURL, token) 376 | 377 | BeforeEach(func() { 378 | if remoteURL == "" || token == "" { 379 | Skip("GITHUB_URL and/or GITHUB_TOKEN environment variables not set") 380 | } 381 | 382 | if providerErr != nil { 383 | Fail(fmt.Sprintf("Provider initialization failed: %s", providerErr)) 384 | } 385 | }) 386 | 387 | var err error 388 | var prID int 389 | now := time.Now() 390 | state := &PRState{ 391 | Env: "dev", 392 | Group: "testgroup", 393 | App: "testapp", 394 | Tag: now.Format("20060102150405"), 395 | Sha: "", 396 | } 397 | 398 | JustBeforeEach(func() { 399 | err = provider.MergePR(ctx, prID, state.Sha) 400 | }) 401 | 402 | When("Merging PR with empty prID and SHA", func() { 403 | It("return error", func() { 404 | var gitHubError *github.ErrorResponse 405 | ok := errors.As(err, &gitHubError) 406 | Expect(ok).To(Equal(true)) 407 | body, bodyErr := ioutil.ReadAll(gitHubError.Response.Body) 408 | Expect(bodyErr).To(BeNil()) 409 | bodyErr = gitHubError.Response.Body.Close() 410 | Expect(bodyErr).To(BeNil()) 411 | 412 | Expect(string(body)).To(ContainSubstring("\"message\":\"Not Found\"")) 413 | }) 414 | }) 415 | 416 | When("Merging PR with existing prID and SHA", func() { 417 | BeforeEach(func() { 418 | branchName := randomBranchName("testing-merge-pr") 419 | repo1 := cloneTestRepoWithNewBranch(ctx, branchName) 420 | pushBranch(repo1, branchName) 421 | repo2 := cloneTestRepoOnExistingBranch(ctx, branchName) 422 | state.Sha = commitAFile(repo2, branchName).String() 423 | pushBranch(repo2, branchName) 424 | title := state.Title() 425 | description, err := state.Description() 426 | Expect(err).To(BeNil()) 427 | _, e := provider.CreatePR(ctx, branchName, false, title, description) 428 | Expect(e).To(BeNil()) 429 | 430 | pr, e := provider.GetPRWithBranch(ctx, branchName, DefaultBranch) 431 | Expect(e).To(BeNil()) 432 | 433 | prID = pr.ID 434 | }) 435 | 436 | It("doesn't return an error", func() { 437 | Expect(err).To(BeNil()) 438 | }) 439 | }) 440 | }) 441 | 442 | var _ = Describe("GitHubGITProvider GetPRWithBranch", func() { 443 | ctx := context.Background() 444 | provider, providerErr := NewGitHubGITProvider(ctx, remoteURL, token) 445 | 446 | BeforeEach(func() { 447 | if remoteURL == "" || token == "" { 448 | Skip("GITHUB_URL and/or GITHUB_TOKEN environment variables not set") 449 | } 450 | 451 | if providerErr != nil { 452 | Fail(fmt.Sprintf("Provider initialization failed: %s", providerErr)) 453 | } 454 | }) 455 | 456 | var err error 457 | var branchName string 458 | var pr PullRequest 459 | now := time.Now() 460 | state := &PRState{ 461 | Env: "dev", 462 | Group: "testgroup", 463 | App: "testapp", 464 | Tag: now.Format("20060102150405"), 465 | Sha: "", 466 | } 467 | 468 | JustBeforeEach(func() { 469 | pr, err = provider.GetPRWithBranch(ctx, branchName, DefaultBranch) 470 | }) 471 | 472 | When("Getting PR with empty branchName", func() { 473 | It("returns an error", func() { 474 | Expect(err.Error()).To(ContainSubstring("no PR found for branches")) 475 | }) 476 | }) 477 | 478 | When("Getting PR with existing branchName", func() { 479 | var origPRId int 480 | BeforeEach(func() { 481 | branchName = randomBranchName("testing-get-pr-with-branch") 482 | 483 | var e error 484 | repo1 := cloneTestRepoWithNewBranch(ctx, branchName) 485 | pushBranch(repo1, branchName) 486 | repo2 := cloneTestRepoOnExistingBranch(ctx, branchName) 487 | state.Sha = commitAFile(repo2, branchName).String() 488 | pushBranch(repo2, branchName) 489 | 490 | title := state.Title() 491 | description, err := state.Description() 492 | Expect(err).To(BeNil()) 493 | origPRId, e = provider.CreatePR(ctx, branchName, false, title, description) 494 | Expect(e).To(BeNil()) 495 | }) 496 | 497 | It("doesn't return an error and ID larger than 0", func() { 498 | Expect(err).To(BeNil()) 499 | Expect(pr.ID).To(Equal(origPRId)) 500 | }) 501 | }) 502 | }) 503 | 504 | var _ = Describe("GitHubGITProvider GetPRThatCausedCommit", func() { 505 | ctx := context.Background() 506 | provider, providerErr := NewGitHubGITProvider(ctx, remoteURL, token) 507 | 508 | BeforeEach(func() { 509 | if remoteURL == "" || token == "" { 510 | Skip("GITHUB_URL and/or GITHUB_TOKEN environment variables not set") 511 | } 512 | 513 | if providerErr != nil { 514 | Fail(fmt.Sprintf("Provider initialization failed: %s", providerErr)) 515 | } 516 | }) 517 | 518 | var err error 519 | var pr PullRequest 520 | var mergedPR PullRequest 521 | now := time.Now() 522 | state := &PRState{ 523 | Env: "dev", 524 | Group: "testgroup", 525 | App: "testapp", 526 | Tag: now.Format("20060102150405"), 527 | Sha: "", 528 | } 529 | 530 | JustBeforeEach(func() { 531 | pr, err = provider.GetPRThatCausedCommit(ctx, state.Sha) 532 | }) 533 | 534 | When("Getting PR with empty SHA", func() { 535 | It("returns an error", func() { 536 | Expect(err.Error()).To(ContainSubstring("no PR found for sha:")) 537 | }) 538 | }) 539 | 540 | When("Getting PR with existing SHA", func() { 541 | BeforeEach(func() { 542 | branchName := randomBranchName("testing-get-pr-that-caused-commit") 543 | repo1 := cloneTestRepoWithNewBranch(ctx, branchName) 544 | pushBranch(repo1, branchName) 545 | repo2 := cloneTestRepoOnExistingBranch(ctx, branchName) 546 | commitSha := commitAFile(repo2, branchName) 547 | pushBranch(repo2, branchName) 548 | 549 | title := state.Title() 550 | description, err := state.Description() 551 | Expect(err).To(BeNil()) 552 | _, e := provider.CreatePR(ctx, branchName, false, title, description) 553 | Expect(e).To(BeNil()) 554 | 555 | mergedPR, e = provider.GetPRWithBranch(ctx, branchName, DefaultBranch) 556 | Expect(e).To(BeNil()) 557 | 558 | e = provider.MergePR(ctx, mergedPR.ID, commitSha.String()) 559 | Expect(e).To(BeNil()) 560 | 561 | repo3 := cloneTestRepoOnExistingBranch(ctx, DefaultBranch) 562 | mergeSha, e := repo3.GetCurrentCommit() 563 | Expect(e).To(BeNil()) 564 | 565 | state.Sha = mergeSha.String() 566 | }) 567 | 568 | It("doesn't return an error and pr.ID equals mergedPR.ID", func() { 569 | Expect(err).To(BeNil()) 570 | Expect(pr.ID).To(Equal(mergedPR.ID)) 571 | }) 572 | }) 573 | }) 574 | -------------------------------------------------------------------------------- /pkg/git/provider.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type ProviderType string 9 | 10 | const ( 11 | ProviderTypeAzdo ProviderType = "azdo" 12 | ProviderTypeGitHub ProviderType = "github" 13 | ) 14 | 15 | type GitProvider interface { 16 | GetStatus(ctx context.Context, sha, group, env string) (CommitStatus, error) 17 | SetStatus(ctx context.Context, sha string, group string, env string, succeeded bool) error 18 | CreatePR(ctx context.Context, branchName string, auto bool, title, description string) (int, error) 19 | GetPRWithBranch(ctx context.Context, source, target string) (PullRequest, error) 20 | GetPRThatCausedCommit(ctx context.Context, sha string) (PullRequest, error) 21 | MergePR(ctx context.Context, ID int, sha string) error 22 | } 23 | 24 | func NewGitProvider(ctx context.Context, providerType ProviderType, remoteURL, token string) (GitProvider, error) { 25 | switch providerType { 26 | case ProviderTypeAzdo: 27 | return NewAzdoGITProvider(ctx, remoteURL, token) 28 | case ProviderTypeGitHub: 29 | return NewGitHubGITProvider(ctx, remoteURL, token) 30 | default: 31 | return nil, fmt.Errorf("unknown provider type: %s", providerType) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/git/provider_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewGitProvider(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | providerType ProviderType 14 | remoteURL string 15 | token string 16 | expectedErr string 17 | }{ 18 | { 19 | name: "azdo provider returns error", 20 | providerType: ProviderTypeAzdo, 21 | remoteURL: "https://dev.azure.com/organization/project/_git/repository", 22 | token: "fake", 23 | expectedErr: "TF400813: The user 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' is not authorized to access this resource.", 24 | }, 25 | { 26 | name: "fake provider returns error", 27 | providerType: ProviderType("fake"), 28 | remoteURL: "", 29 | token: "", 30 | expectedErr: "unknown provider type: fake", 31 | }, 32 | } 33 | 34 | for i, tt := range tests { 35 | t.Logf("Test iteration %d: %s", i, tt.name) 36 | t.Run(tt.name, func(t *testing.T) { 37 | _, err := NewGitProvider(context.TODO(), tt.providerType, tt.remoteURL, tt.token) 38 | require.EqualError(t, err, tt.expectedErr) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pkg/git/pull_request.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | kustomizationFileName = "kustomization.yaml" 12 | ) 13 | 14 | type PRType string 15 | 16 | const ( 17 | PRTypePromote PRType = "promote" 18 | PRTypeFeature PRType = "feature" 19 | ) 20 | 21 | type PullRequest struct { 22 | ID int 23 | Title string 24 | Description string 25 | State *PRState 26 | } 27 | 28 | func NewPullRequest(id *int, title *string, description *string) (PullRequest, error) { 29 | if id == nil { 30 | return PullRequest{}, fmt.Errorf("id can't be empty") 31 | } 32 | if title == nil { 33 | return PullRequest{}, fmt.Errorf("title can't be empty") 34 | } 35 | d := "" 36 | if description != nil { 37 | d = *description 38 | } 39 | state, _, err := NewPRState(d) 40 | if err != nil { 41 | return PullRequest{}, err 42 | } 43 | return PullRequest{ 44 | ID: *id, 45 | Title: *title, 46 | Description: d, 47 | State: state, 48 | }, nil 49 | } 50 | 51 | type PRState struct { 52 | Group string `json:"group"` 53 | App string `json:"app"` 54 | Tag string `json:"tag"` 55 | Env string `json:"env"` 56 | Sha string `json:"sha"` 57 | Feature string `json:"feature"` 58 | Type PRType `json:"type"` 59 | } 60 | 61 | // NewPRState takes the content of a pull rquest description and coverts 62 | // it to a PRState. No error will be returned if the description does not 63 | // contain state metadata, but the bool value will be false. 64 | func NewPRState(description string) (*PRState, bool, error) { 65 | // Check if the body contains state data. If it does not it should return nil. 66 | comp := strings.Split(description, " -->") 67 | if len(comp) < 2 { 68 | return nil, false, nil 69 | } 70 | comp = strings.Split(comp[0], " 123 | ENV: %s 124 | APP: %s 125 | TAG: %s`, string(jsonString), p.Env, p.App, p.Tag) 126 | return description, nil 127 | } 128 | 129 | func (p *PRState) EnvPath() string { 130 | return filepath.Join(p.Group, p.Env) 131 | } 132 | 133 | func (p *PRState) EnvKustomizationPath() string { 134 | return filepath.Join(p.EnvPath(), kustomizationFileName) 135 | } 136 | 137 | func (p *PRState) AppPath() string { 138 | return filepath.Join(p.EnvPath(), fmt.Sprintf("%s-%s", p.App, p.Feature)) 139 | } 140 | 141 | func (p *PRState) AppKustomizationPath() string { 142 | return filepath.Join(p.AppPath(), kustomizationFileName) 143 | } 144 | -------------------------------------------------------------------------------- /pkg/git/pull_request_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewPullRequest(t *testing.T) { 11 | cases := []struct { 12 | id *int 13 | title *string 14 | description *string 15 | expectedErr string 16 | }{ 17 | { 18 | id: toIntPtr(1), 19 | title: toStringPtr("testTitle"), 20 | description: toStringPtr("test description"), 21 | expectedErr: "", 22 | }, 23 | { 24 | id: toIntPtr(1), 25 | title: toStringPtr("testTitle"), 26 | description: toStringPtr("test description"), 27 | expectedErr: "", 28 | }, 29 | { 30 | id: toIntPtr(1), 31 | title: toStringPtr("testTitle"), 32 | description: toStringPtr("test description"), 33 | expectedErr: "", 34 | }, 35 | { 36 | id: toIntPtr(1), 37 | title: toStringPtr("testTitle"), 38 | description: nil, 39 | expectedErr: "", 40 | }, 41 | { 42 | id: nil, 43 | title: toStringPtr("testTitle"), 44 | description: toStringPtr("test description"), 45 | expectedErr: "id can't be empty", 46 | }, 47 | { 48 | id: toIntPtr(1), 49 | title: nil, 50 | description: toStringPtr("test description"), 51 | expectedErr: "title can't be empty", 52 | }, 53 | } 54 | for _, c := range cases { 55 | _, err := NewPullRequest(c.id, c.title, c.description) 56 | if c.expectedErr != "" { 57 | require.EqualError(t, err, c.expectedErr) 58 | } else { 59 | require.NoError(t, err) 60 | } 61 | } 62 | } 63 | 64 | func toStringPtr(s string) *string { 65 | return &s 66 | } 67 | 68 | func toIntPtr(i int) *int { 69 | return &i 70 | } 71 | 72 | func TestPRStateValid(t *testing.T) { 73 | json := `{"group":"g","app":"a","tag":"t","env":"e","sha":"s","feature":"","type":"promote"}` 74 | description := fmt.Sprintf("\n\tENV: e\n\tAPP: a\n\tTAG: t", json) 75 | state, ok, err := NewPRState(description) 76 | require.NoError(t, err) 77 | require.True(t, ok) 78 | require.Equal(t, "g", state.Group) 79 | require.Equal(t, "a", state.App) 80 | require.Equal(t, "t", state.Tag) 81 | require.Equal(t, "e", state.Env) 82 | require.Equal(t, "s", state.Sha) 83 | require.Equal(t, PRTypePromote, state.Type) 84 | genDescription, err := state.Description() 85 | require.NoError(t, err) 86 | require.Equal(t, description, genDescription) 87 | } 88 | 89 | func TestPRStateInvalid(t *testing.T) { 90 | description := "" 91 | _, ok, err := NewPRState(description) 92 | require.Error(t, err) 93 | require.False(t, ok) 94 | } 95 | 96 | func TestPRStateNone(t *testing.T) { 97 | description := "Some other data" 98 | state, ok, err := NewPRState(description) 99 | require.NoError(t, err) 100 | require.False(t, ok) 101 | require.Nil(t, state) 102 | } 103 | 104 | func TestPRStateBranchName(t *testing.T) { 105 | cases := []struct { 106 | name string 107 | state PRState 108 | includeEnv bool 109 | expectedBranchName string 110 | }{ 111 | { 112 | name: "promote no env", 113 | state: PRState{ 114 | Group: "group", 115 | App: "app", 116 | Tag: "tag", 117 | Env: "dev", 118 | Type: PRTypePromote, 119 | }, 120 | includeEnv: false, 121 | expectedBranchName: "promote/group-app", 122 | }, 123 | { 124 | name: "promote include env", 125 | state: PRState{ 126 | Group: "group", 127 | App: "app", 128 | Tag: "tag", 129 | Env: "dev", 130 | Type: PRTypePromote, 131 | }, 132 | includeEnv: true, 133 | expectedBranchName: "promote/dev/group-app", 134 | }, 135 | { 136 | name: "feature no env", 137 | state: PRState{ 138 | Group: "group", 139 | App: "app", 140 | Tag: "tag", 141 | Env: "dev", 142 | Feature: "feature", 143 | Type: PRTypeFeature, 144 | }, 145 | includeEnv: false, 146 | expectedBranchName: "feature/group-app-feature", 147 | }, 148 | { 149 | name: "feature include env", 150 | state: PRState{ 151 | Group: "group", 152 | App: "app", 153 | Tag: "tag", 154 | Env: "dev", 155 | Feature: "feature", 156 | Type: PRTypeFeature, 157 | }, 158 | includeEnv: true, 159 | expectedBranchName: "feature/dev/group-app-feature", 160 | }, 161 | } 162 | for _, c := range cases { 163 | t.Run(c.name, func(t *testing.T) { 164 | require.Equal(t, c.expectedBranchName, c.state.BranchName(c.includeEnv)) 165 | }) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /pkg/git/util.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | giturls "github.com/whilp/git-urls" 8 | ) 9 | 10 | // ParseGitAddress ... 11 | func ParseGitAddress(s string) (host string, id string, err error) { 12 | u, err := giturls.Parse(s) 13 | if err != nil { 14 | return "", "", err 15 | } 16 | 17 | scheme := u.Scheme 18 | if u.Scheme == "ssh" { 19 | scheme = "https" 20 | } 21 | 22 | id = strings.TrimLeft(u.Path, "/") 23 | id = strings.TrimSuffix(id, ".git") 24 | host = fmt.Sprintf("%s://%s", scheme, u.Host) 25 | return host, id, nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/git/util_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestParseGitAddress(t *testing.T) { 10 | cases := []struct { 11 | input string 12 | expectedHost string 13 | expectedID string 14 | expectedError string 15 | }{ 16 | { 17 | input: "https://dev.azure.com/organization/project/_git/repository", 18 | expectedHost: "https://dev.azure.com", 19 | expectedID: "organization/project/_git/repository", 20 | expectedError: "", 21 | }, 22 | { 23 | input: "https://user@dev.azure.com/organization/project/_git/repository", 24 | expectedHost: "https://dev.azure.com", 25 | expectedID: "organization/project/_git/repository", 26 | expectedError: "", 27 | }, 28 | { 29 | input: "ssh://dev.azure.com/organization/project/_git/repository", 30 | expectedHost: "https://dev.azure.com", 31 | expectedID: "organization/project/_git/repository", 32 | expectedError: "", 33 | }, 34 | { 35 | input: "https://dev.azure.com/organization/project/_git/repository.git", 36 | expectedHost: "https://dev.azure.com", 37 | expectedID: "organization/project/_git/repository", 38 | expectedError: "", 39 | }, 40 | { 41 | input: "/tmp/organization/project/_git/repository.git", 42 | expectedHost: "file://", 43 | expectedID: "tmp/organization/project/_git/repository", 44 | expectedError: "", 45 | }, 46 | } 47 | 48 | for _, c := range cases { 49 | host, id, err := ParseGitAddress(c.input) 50 | if c.expectedError != "" { 51 | require.EqualError(t, err, c.expectedError) 52 | } 53 | 54 | if c.expectedError == "" { 55 | require.Equal(t, c.expectedHost, host) 56 | require.Equal(t, c.expectedID, id) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/manifest/image.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/fluxcd/image-automation-controller/pkg/update" 8 | imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1beta1" 9 | "github.com/go-logr/logr" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | // UpdateImageTag changes the image tag in the kustomization file. 14 | // TODO: Should change to using fs objects. 15 | func UpdateImageTag(path, app, group, tag string) error { 16 | policies := []imagev1_reflect.ImagePolicy{ 17 | { 18 | ObjectMeta: metav1.ObjectMeta{ 19 | Name: app, 20 | Namespace: group, 21 | }, 22 | Status: imagev1_reflect.ImagePolicyStatus{ 23 | LatestImage: fmt.Sprintf("%s:%s", app, tag), 24 | }, 25 | }, 26 | } 27 | log.Printf("Updating images with %s:%s:%s in %s\n", group, app, tag, path) 28 | _, err := update.UpdateWithSetters(logr.Discard(), path, path, policies) 29 | if err != nil { 30 | return fmt.Errorf("failed updating manifests: %w", err) 31 | } 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/manifest/image_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/xenitab/gitops-promotion/pkg/git" 10 | ) 11 | 12 | func TestUpdateImageTag(t *testing.T) { 13 | cases := []struct { 14 | state git.PRState 15 | before string 16 | after string 17 | expectedErrContains string 18 | expectedMatch bool 19 | }{ 20 | { 21 | state: git.PRState{ 22 | Env: "dev", 23 | Group: "team1", 24 | App: "app1", 25 | Tag: "v1.0.1", 26 | }, 27 | before: `random: test 28 | image: app1:v1.0.0 # {"$imagepolicy": "team1:app1"} 29 | why: true 30 | `, 31 | after: `random: test 32 | image: app1:v1.0.1 # {"$imagepolicy": "team1:app1"} 33 | why: true 34 | `, 35 | expectedErrContains: "", 36 | expectedMatch: true, 37 | }, 38 | { 39 | state: git.PRState{ 40 | Env: "dev", 41 | Group: "team1", 42 | App: "app1", 43 | Tag: "v1.0.1", 44 | }, 45 | before: `random: test 46 | tag: v1.0.0 # {"$imagepolicy": "team1:app1:tag"} 47 | why: true 48 | `, 49 | after: `random: test 50 | tag: v1.0.1 # {"$imagepolicy": "team1:app1:tag"} 51 | why: true 52 | `, 53 | expectedErrContains: "", 54 | expectedMatch: true, 55 | }, 56 | { 57 | state: git.PRState{ 58 | Env: "dev", 59 | Group: "team1", 60 | App: "app1", 61 | Tag: "v1.0.1", 62 | }, 63 | before: `random: test 64 | tag: "1234" # {"$imagepolicy": "team1:app1:tag"} 65 | why: true 66 | `, 67 | after: `random: test 68 | tag: "v1.0.1" # {"$imagepolicy": "team1:app1:tag"} 69 | why: true 70 | `, 71 | expectedErrContains: "", 72 | expectedMatch: true, 73 | }, 74 | { 75 | state: git.PRState{ 76 | Env: "dev", 77 | Group: "team1", 78 | App: "app1", 79 | Tag: "v1.0.1", 80 | }, 81 | before: `random: test 82 | tag: 1234 # {"$imagepolicy": "team1:app1:tag"} 83 | why: true 84 | `, 85 | after: `random: test 86 | tag: v1.0.1 # {"$imagepolicy": "team1:app1:tag"} 87 | why: true 88 | `, 89 | expectedErrContains: "", 90 | expectedMatch: false, // This is a know issue: https://github.com/XenitAB/gitops-promotion/issues/32 91 | }, 92 | } 93 | 94 | for i, c := range cases { 95 | dir, err := os.MkdirTemp("", fmt.Sprintf("%d", i)) 96 | if err != nil && c.expectedErrContains == "" { 97 | t.Errorf("Expected err to be nil: %q", err) 98 | } 99 | 100 | defer os.RemoveAll(dir) 101 | 102 | testFile := fmt.Sprintf("%s/%s.yaml", dir, c.state.App) 103 | err = os.WriteFile(testFile, []byte(c.before), 0600) 104 | if err != nil && c.expectedErrContains == "" { 105 | t.Errorf("Expected err to be nil: %q", err) 106 | } 107 | 108 | err = UpdateImageTag(dir, c.state.App, c.state.Group, c.state.Tag) 109 | if err == nil && c.expectedErrContains != "" { 110 | t.Errorf("Expected err not to be nil") 111 | } 112 | 113 | if err != nil && c.expectedErrContains != "" { 114 | if !strings.Contains(err.Error(), c.expectedErrContains) { 115 | t.Errorf("Expected err to contain '%q' but received: %q", c.expectedErrContains, err.Error()) 116 | } 117 | } 118 | 119 | if err == nil && c.expectedErrContains == "" { 120 | result, err := os.ReadFile(testFile) 121 | if err != nil && c.expectedErrContains == "" { 122 | t.Errorf("Expected err to be nil: %q", err) 123 | } 124 | 125 | if string(result) != c.after && c.expectedMatch { 126 | t.Errorf("\nExpected:\n%s\n\nReceived:\n%s\n", c.after, string(result)) 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pkg/manifest/kustomization.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/spf13/afero" 9 | appsv1 "k8s.io/api/apps/v1" 10 | networkingv1 "k8s.io/api/networking/v1" 11 | "k8s.io/apimachinery/pkg/labels" 12 | "sigs.k8s.io/kustomize/api/image" 13 | "sigs.k8s.io/kustomize/api/krusty" 14 | "sigs.k8s.io/kustomize/api/resource" 15 | kustypes "sigs.k8s.io/kustomize/api/types" 16 | kyaml "sigs.k8s.io/kustomize/kyaml/yaml" 17 | "sigs.k8s.io/yaml" 18 | 19 | "github.com/xenitab/gitops-promotion/pkg/git" 20 | ) 21 | 22 | const ( 23 | featureLabel = "gitops-promotion.xenit.io/feature" 24 | ) 25 | 26 | // DuplicateApplication duplicates the application manifests based on the label selector. 27 | // It assumes that the fs is a base fs in the repository directory. 28 | // 29 | //nolint:gocognit,gocritic // ignore 30 | func DuplicateApplication(fs afero.Fs, state git.PRState, labelSelector map[string]string) error { 31 | // Write feature app manifests 32 | resources, err := manfifestsMatchingSelector(fs, state.EnvPath(), labelSelector) 33 | if err != nil { 34 | return fmt.Errorf("could not get manifets from selector: %w", err) 35 | } 36 | dirExists, err := createOrReplaceDirectory(fs, state.AppPath()) 37 | if err != nil { 38 | return err 39 | } 40 | commonLabels := map[string]string{featureLabel: state.Feature} 41 | for k, v := range labelSelector { 42 | commonLabels[k] = fmt.Sprintf("%s-%s", v, state.Feature) 43 | } 44 | kustomization := &kustypes.Kustomization{} 45 | kustomization.NameSuffix = fmt.Sprintf("-%s", state.Feature) 46 | kustomization.CommonLabels = commonLabels 47 | for _, res := range resources { 48 | b, err := patchResource(res, state.Tag, state.Feature) 49 | if err != nil { 50 | return err 51 | } 52 | id := strings.Join([]string{res.GetGvk().String(), res.GetNamespace(), res.GetName()}, "-") 53 | id = fmt.Sprintf("%s.yaml", id) 54 | if err := afero.WriteFile(fs, filepath.Join(state.AppPath(), id), b, 0600); err != nil { 55 | return err 56 | } 57 | kustomization.Resources = append(kustomization.Resources, id) 58 | } 59 | errStrings := kustomization.EnforceFields() 60 | if len(errStrings) != 0 { 61 | return fmt.Errorf("%s", strings.Join(errStrings, ", ")) 62 | } 63 | b, err := yaml.Marshal(kustomization) 64 | if err != nil { 65 | return err 66 | } 67 | if err := afero.WriteFile(fs, state.AppKustomizationPath(), b, 0600); err != nil { 68 | return err 69 | } 70 | 71 | // Append feature kustomization to root resources if it does not exist 72 | // TODO: Replace with a separate check instead 73 | if !dirExists { 74 | b, err = afero.ReadFile(fs, state.EnvKustomizationPath()) 75 | if err != nil { 76 | return err 77 | } 78 | node, err := kyaml.Parse(string(b)) 79 | if err != nil { 80 | return err 81 | } 82 | // TODO: Replace with function from state 83 | resourcePath := fmt.Sprintf("%s-%s", state.App, state.Feature) 84 | rNode := node.Field("resources") 85 | yNode := rNode.Value.YNode() 86 | yNode.Content = append(yNode.Content, kyaml.NewStringRNode(resourcePath).YNode()) 87 | rNode.Value.SetYNode(yNode) 88 | data, err := node.String() 89 | if err != nil { 90 | return err 91 | } 92 | if err := afero.WriteFile(fs, state.EnvKustomizationPath(), []byte(data), 0600); err != nil { 93 | return err 94 | } 95 | } 96 | 97 | return nil 98 | } 99 | 100 | //nolint:gocritic // ignore 101 | func RemoveApplication(fs afero.Fs, state git.PRState) error { 102 | // Remove application manifest directory 103 | err := fs.RemoveAll(state.AppPath()) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // Remove reference to path in environment Kustomization 109 | b, err := afero.ReadFile(fs, state.EnvKustomizationPath()) 110 | if err != nil { 111 | return err 112 | } 113 | node, err := kyaml.Parse(string(b)) 114 | if err != nil { 115 | return err 116 | } 117 | rNode := node.Field("resources") 118 | yNode := rNode.Value.YNode() 119 | newContent := []*kyaml.Node{} 120 | for _, content := range yNode.Content { 121 | if content.Value == fmt.Sprintf("%s-%s", state.App, state.Feature) { 122 | continue 123 | } 124 | newContent = append(newContent, content) 125 | } 126 | yNode.Content = newContent 127 | rNode.Value.SetYNode(yNode) 128 | data, err := node.String() 129 | if err != nil { 130 | return err 131 | } 132 | if err := afero.WriteFile(fs, state.EnvKustomizationPath(), []byte(data), 0600); err != nil { 133 | return err 134 | } 135 | return nil 136 | } 137 | 138 | func manfifestsMatchingSelector(fs afero.Fs, path string, labelSelector map[string]string) ([]*resource.Resource, error) { 139 | selector, err := labels.ValidatedSelectorFromSet(labelSelector) 140 | if err != nil { 141 | return nil, fmt.Errorf("could not create label selector: %w", err) 142 | } 143 | selectorString := selector.String() 144 | if selectorString == "" { 145 | return nil, fmt.Errorf("selector string should not be empty") 146 | } 147 | selectorString = fmt.Sprintf("%s,!%s", selectorString, featureLabel) 148 | kSelector := &kustypes.Selector{LabelSelector: selectorString} 149 | k := krusty.MakeKustomizer(krusty.MakeDefaultOptions()) 150 | resMap, err := k.Run(NewKustomizeFs(fs), path) 151 | if err != nil { 152 | return nil, fmt.Errorf("could not build kustomization: %w", err) 153 | } 154 | resources, err := resMap.Select(*kSelector) 155 | if err != nil { 156 | return nil, err 157 | } 158 | if len(resources) == 0 { 159 | return nil, fmt.Errorf("returned resources is an empty list") 160 | } 161 | return resources, nil 162 | } 163 | 164 | func patchResource(res *resource.Resource, tag, feature string) ([]byte, error) { 165 | b, err := res.AsYAML() 166 | if err != nil { 167 | return nil, err 168 | } 169 | switch res.GetKind() { 170 | case "Ingress": 171 | b, err = patchIngress(b, feature) 172 | if err != nil { 173 | return nil, err 174 | } 175 | case "Deployment": 176 | b, err = patchDeployment(b, tag) 177 | if err != nil { 178 | return nil, err 179 | } 180 | return b, nil 181 | } 182 | return b, nil 183 | } 184 | 185 | func patchIngress(b []byte, feature string) ([]byte, error) { 186 | ingress := &networkingv1.Ingress{} 187 | err := yaml.Unmarshal(b, ingress) 188 | if err != nil { 189 | return nil, err 190 | } 191 | for i, rule := range ingress.Spec.Rules { 192 | ingress.Spec.Rules[i].Host = fmt.Sprintf("%s.%s", feature, rule.Host) 193 | } 194 | //nolint:gocritic // ignore 195 | for i, tls := range ingress.Spec.TLS { 196 | for j, host := range tls.Hosts { 197 | ingress.Spec.TLS[i].Hosts[j] = fmt.Sprintf("%s.%s", feature, host) 198 | } 199 | } 200 | return yaml.Marshal(ingress) 201 | } 202 | 203 | func patchDeployment(b []byte, tag string) ([]byte, error) { 204 | deployment := &appsv1.Deployment{} 205 | err := yaml.Unmarshal(b, deployment) 206 | if err != nil { 207 | return nil, err 208 | } 209 | // TODO: Do not override every single image tag 210 | //nolint:gocritic // ignore 211 | for i, container := range deployment.Spec.Template.Spec.Containers { 212 | name, _ := image.Split(container.Image) 213 | deployment.Spec.Template.Spec.Containers[i].Image = fmt.Sprintf("%s:%s", name, tag) 214 | } 215 | return yaml.Marshal(deployment) 216 | } 217 | -------------------------------------------------------------------------------- /pkg/manifest/kustomization_fs.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | 7 | "github.com/spf13/afero" 8 | "sigs.k8s.io/kustomize/kyaml/filesys" 9 | ) 10 | 11 | type KustomizeFs struct { 12 | fs afero.Fs 13 | } 14 | 15 | func NewKustomizeFs(fs afero.Fs) KustomizeFs { 16 | return KustomizeFs{fs} 17 | } 18 | 19 | func (kfs KustomizeFs) Create(path string) (filesys.File, error) { 20 | return kfs.fs.Create(path) 21 | } 22 | 23 | func (kfs KustomizeFs) Mkdir(path string) error { 24 | return kfs.fs.Mkdir(path, 0755) 25 | } 26 | 27 | func (kfs KustomizeFs) MkdirAll(path string) error { 28 | return kfs.fs.MkdirAll(path, 0755) 29 | } 30 | 31 | func (kfs KustomizeFs) RemoveAll(path string) error { 32 | return kfs.fs.RemoveAll(path) 33 | } 34 | 35 | func (kfs KustomizeFs) Open(path string) (filesys.File, error) { 36 | return kfs.fs.Open(path) 37 | } 38 | 39 | func (kfs KustomizeFs) IsDir(path string) bool { 40 | ok, err := afero.IsDir(kfs.fs, path) 41 | if err != nil { 42 | return false 43 | } 44 | return ok 45 | } 46 | 47 | func (kfs KustomizeFs) ReadDir(path string) ([]string, error) { 48 | fileInfos, err := afero.ReadDir(kfs.fs, path) 49 | if err != nil { 50 | return nil, err 51 | } 52 | paths := []string{} 53 | for _, fileInfo := range fileInfos { 54 | paths = append(paths, fileInfo.Name()) 55 | } 56 | return paths, nil 57 | } 58 | 59 | func (kfs KustomizeFs) CleanedAbs(path string) (filesys.ConfirmedDir, string, error) { 60 | if kfs.IsDir(path) { 61 | return filesys.ConfirmedDir(path), "", nil 62 | } 63 | d := filepath.Dir(path) 64 | if !kfs.IsDir(d) { 65 | // Programmer/assumption error. 66 | log.Fatalf("first part of '%s' not a directory", path) 67 | } 68 | if d == path { 69 | // Programmer/assumption error. 70 | log.Fatalf("d '%s' should be a subset of deLinked", d) 71 | } 72 | f := filepath.Base(path) 73 | if filepath.Join(d, f) != path { 74 | // Programmer/assumption error. 75 | log.Fatalf("these should be equal: '%s', '%s'", filepath.Join(d, f), path) 76 | } 77 | return filesys.ConfirmedDir(d), f, nil 78 | } 79 | 80 | func (kfs KustomizeFs) Exists(path string) bool { 81 | ok, err := afero.Exists(kfs.fs, path) 82 | if err != nil { 83 | return false 84 | } 85 | return ok 86 | } 87 | 88 | func (kfs KustomizeFs) Glob(pattern string) ([]string, error) { 89 | return afero.Glob(kfs.fs, pattern) 90 | } 91 | 92 | func (kfs KustomizeFs) ReadFile(path string) ([]byte, error) { 93 | return afero.ReadFile(kfs.fs, path) 94 | } 95 | 96 | func (kfs KustomizeFs) WriteFile(path string, data []byte) error { 97 | return afero.WriteFile(kfs.fs, path, data, 0600) 98 | } 99 | 100 | func (kfs KustomizeFs) Walk(path string, walkFn filepath.WalkFunc) error { 101 | return afero.Walk(kfs.fs, path, walkFn) 102 | } 103 | -------------------------------------------------------------------------------- /pkg/manifest/kustomization_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/spf13/afero" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/xenitab/gitops-promotion/pkg/git" 11 | ) 12 | 13 | func testFileContains(t *testing.T, fs afero.Fs, path string, expectedContent string) { 14 | t.Helper() 15 | content, err := afero.ReadFile(fs, path) 16 | require.NoError(t, err) 17 | require.Equal(t, expectedContent, string(content)) 18 | } 19 | 20 | func TestDuplicateAndRemoveApplication(t *testing.T) { 21 | osFs := afero.NewBasePathFs(afero.NewOsFs(), "./testdata/duplicate-application") 22 | memFs := afero.NewMemMapFs() 23 | fs := afero.NewCopyOnWriteFs(osFs, memFs) 24 | 25 | state := git.PRState{ 26 | Group: "apps", 27 | App: "nginx", 28 | Env: "dev", 29 | Feature: "feature", 30 | } 31 | for _, tag := range []string{"1234", "abcd", "12ab"} { 32 | state.Tag = tag 33 | 34 | err := DuplicateApplication(fs, state, map[string]string{"app": "nginx"}) 35 | require.NoError(t, err) 36 | 37 | expectedRootKustomization := `resources: 38 | - ../base 39 | - existing-feature 40 | - nginx-feature 41 | images: 42 | - name: nginx 43 | newTag: app # {"$imagepolicy": "apps:nginx:tag"} 44 | ` 45 | testFileContains(t, memFs, state.EnvKustomizationPath(), expectedRootKustomization) 46 | 47 | expectedFeatureKustomization := `commonLabels: 48 | app: nginx-feature 49 | gitops-promotion.xenit.io/feature: feature 50 | nameSuffix: -feature 51 | resources: 52 | - apps_v1_Deployment--nginx.yaml 53 | - ~G_v1_Service--nginx.yaml 54 | ` 55 | testFileContains(t, memFs, state.AppKustomizationPath(), expectedFeatureKustomization) 56 | 57 | fis, err := afero.ReadDir(memFs, state.AppPath()) 58 | require.NoError(t, err) 59 | files := []string{} 60 | for _, fi := range fis { 61 | files = append(files, fi.Name()) 62 | } 63 | require.Equal(t, []string{"apps_v1_Deployment--nginx.yaml", "kustomization.yaml", "~G_v1_Service--nginx.yaml"}, files) 64 | } 65 | 66 | err := RemoveApplication(fs, state) 67 | require.NoError(t, err) 68 | expectedKustomization := `resources: 69 | - ../base 70 | - existing-feature 71 | images: 72 | - name: nginx 73 | newTag: app # {"$imagepolicy": "apps:nginx:tag"} 74 | ` 75 | testFileContains(t, memFs, state.EnvKustomizationPath(), expectedKustomization) 76 | _, err = fs.Stat(state.AppPath()) 77 | require.EqualError(t, err, "stat testdata/duplicate-application/apps/dev/nginx-feature: no such file or directory") 78 | } 79 | 80 | func TestPatchIngress(t *testing.T) { 81 | yamlTemplate := `apiVersion: networking.k8s.io/v1 82 | kind: Ingress 83 | metadata: 84 | creationTimestamp: null 85 | name: test 86 | spec: 87 | rules: 88 | - host: %s 89 | http: 90 | paths: 91 | - backend: 92 | service: 93 | name: service 94 | port: 95 | number: 80 96 | path: / 97 | pathType: Prefix 98 | status: 99 | loadBalancer: {} 100 | ` 101 | 102 | cases := []struct { 103 | name string 104 | domain string 105 | feature string 106 | expected string 107 | }{ 108 | { 109 | name: "simple", 110 | domain: "foo.bar.example", 111 | feature: "baz", 112 | expected: "baz.foo.bar.example", 113 | }, 114 | } 115 | for _, c := range cases { 116 | t.Run(c.name, func(t *testing.T) { 117 | b, err := patchIngress([]byte(fmt.Sprintf(yamlTemplate, c.domain)), c.feature) 118 | require.NoError(t, err) 119 | require.Equal(t, fmt.Sprintf(yamlTemplate, c.expected), string(b)) 120 | }) 121 | } 122 | } 123 | 124 | func TestPatchDeployment(t *testing.T) { 125 | yaml := `apiVersion: apps/v1 126 | kind: Deployment 127 | metadata: 128 | name: nginx-deployment 129 | labels: 130 | app: nginx 131 | spec: 132 | replicas: 3 133 | selector: 134 | matchLabels: 135 | app: nginx 136 | template: 137 | metadata: 138 | labels: 139 | app: nginx 140 | spec: 141 | containers: 142 | - name: nginx 143 | image: nginx:1.14.2 144 | ports: 145 | - containerPort: 80 146 | ` 147 | b, err := patchDeployment([]byte(yaml), "foobar") 148 | require.NoError(t, err) 149 | expectedYaml := `apiVersion: apps/v1 150 | kind: Deployment 151 | metadata: 152 | creationTimestamp: null 153 | labels: 154 | app: nginx 155 | name: nginx-deployment 156 | spec: 157 | replicas: 3 158 | selector: 159 | matchLabels: 160 | app: nginx 161 | strategy: {} 162 | template: 163 | metadata: 164 | creationTimestamp: null 165 | labels: 166 | app: nginx 167 | spec: 168 | containers: 169 | - image: nginx:foobar 170 | name: nginx 171 | ports: 172 | - containerPort: 80 173 | resources: {} 174 | status: {} 175 | ` 176 | require.Equal(t, expectedYaml, string(b)) 177 | } 178 | -------------------------------------------------------------------------------- /pkg/manifest/testdata/duplicate-application/apps/base/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: nginx 5 | labels: 6 | app: nginx 7 | spec: 8 | replicas: 3 9 | selector: 10 | matchLabels: 11 | app: nginx 12 | template: 13 | metadata: 14 | labels: 15 | app: nginx 16 | spec: 17 | containers: 18 | - name: nginx 19 | image: nginx:1.14.2 20 | ports: 21 | - containerPort: 80 22 | -------------------------------------------------------------------------------- /pkg/manifest/testdata/duplicate-application/apps/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - deployment.yaml 3 | - service.yaml 4 | -------------------------------------------------------------------------------- /pkg/manifest/testdata/duplicate-application/apps/base/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: nginx 5 | labels: 6 | app: nginx 7 | spec: 8 | selector: 9 | app: nginx 10 | ports: 11 | - protocol: TCP 12 | port: 80 13 | -------------------------------------------------------------------------------- /pkg/manifest/testdata/duplicate-application/apps/dev/existing-feature/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: nginx 6 | name: nginx 7 | spec: 8 | replicas: 3 9 | selector: 10 | matchLabels: 11 | app: nginx 12 | template: 13 | metadata: 14 | labels: 15 | app: nginx 16 | spec: 17 | containers: 18 | - image: nginx:app 19 | name: nginx 20 | ports: 21 | - containerPort: 80 22 | -------------------------------------------------------------------------------- /pkg/manifest/testdata/duplicate-application/apps/dev/existing-feature/kustomization.yaml: -------------------------------------------------------------------------------- 1 | commonLabels: 2 | gitops-promotion.xenit.io/feature: existing-feautre 3 | nameSuffix: -existing-feature 4 | resources: 5 | - deployment.yaml 6 | - service.yaml 7 | -------------------------------------------------------------------------------- /pkg/manifest/testdata/duplicate-application/apps/dev/existing-feature/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app: nginx 6 | name: nginx 7 | spec: 8 | ports: 9 | - port: 80 10 | protocol: TCP 11 | selector: 12 | app: nginx 13 | -------------------------------------------------------------------------------- /pkg/manifest/testdata/duplicate-application/apps/dev/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - ../base 3 | - existing-feature 4 | images: 5 | - name: nginx 6 | newTag: app # {"$imagepolicy": "apps:nginx:tag"} 7 | -------------------------------------------------------------------------------- /pkg/manifest/util.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import "github.com/spf13/afero" 4 | 5 | // createOrReplaceDirectory will for the given path remove any existing directory 6 | // and then create a new one. 7 | func createOrReplaceDirectory(fs afero.Fs, path string) (bool, error) { 8 | dirExists, err := afero.DirExists(fs, path) 9 | if err != nil { 10 | return false, err 11 | } 12 | if dirExists { 13 | if err := fs.RemoveAll(path); err != nil { 14 | return false, err 15 | } 16 | } 17 | if err := fs.Mkdir(path, 0755); err != nil { 18 | return false, err 19 | } 20 | return dirExists, err 21 | } 22 | -------------------------------------------------------------------------------- /pkg/manifest/util_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/afero" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewDirectory(t *testing.T) { 11 | fs := afero.NewMemMapFs() 12 | path := "./foo/bar" 13 | exists, err := createOrReplaceDirectory(fs, path) 14 | require.NoError(t, err) 15 | require.False(t, exists) 16 | } 17 | 18 | func TestExistingDirectory(t *testing.T) { 19 | fs := afero.NewMemMapFs() 20 | path := "./foo/bar" 21 | err := fs.Mkdir(path, 0655) 22 | require.NoError(t, err) 23 | exists, err := createOrReplaceDirectory(fs, path) 24 | require.NoError(t, err) 25 | require.True(t, exists) 26 | } 27 | -------------------------------------------------------------------------------- /tests/e2e_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/google/go-github/v45/github" 13 | "github.com/stretchr/testify/require" 14 | "github.com/xenitab/gitops-promotion/pkg/command" 15 | "github.com/xenitab/gitops-promotion/pkg/git" 16 | "golang.org/x/oauth2" 17 | ) 18 | 19 | type providerConfig struct { 20 | providerType git.ProviderType 21 | username string 22 | password string 23 | url string 24 | defaultBranch string 25 | } 26 | 27 | func makeGitHubClient(ctx context.Context, config *providerConfig) *github.Client { 28 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: config.password}) 29 | tc := oauth2.NewClient(ctx, ts) 30 | return github.NewClient(tc) 31 | } 32 | 33 | var providers = []providerConfig{ 34 | { 35 | providerType: git.ProviderTypeAzdo, 36 | username: "gitops-promotion", 37 | password: os.Getenv("AZDO_PAT"), 38 | url: os.Getenv("AZDO_URL"), 39 | defaultBranch: "main", 40 | }, 41 | { 42 | providerType: git.ProviderTypeGitHub, 43 | username: "gitops-promotion", 44 | password: os.Getenv("GITHUB_TOKEN"), 45 | url: os.Getenv("GITHUB_URL"), 46 | defaultBranch: "main", 47 | }, 48 | } 49 | 50 | //nolint:gocritic // Using reference will trigger warning that p is a loop variable below 51 | func testSetup(ctx context.Context, config providerConfig) error { 52 | if config.providerType == "github" { 53 | client := makeGitHubClient(ctx, &config) 54 | _, id, err := git.ParseGitAddress(config.url) 55 | if err != nil { 56 | return err 57 | } 58 | comp := strings.Split(id, "/") 59 | owner := comp[0] 60 | repo := comp[1] 61 | _, _, err = client.Repositories.UpdateBranchProtection( 62 | ctx, 63 | owner, 64 | repo, 65 | config.defaultBranch, 66 | &github.ProtectionRequest{ 67 | EnforceAdmins: true, 68 | RequiredStatusChecks: &github.RequiredStatusChecks{ 69 | Strict: true, 70 | Contexts: []string{}, 71 | }, 72 | }) 73 | return err 74 | } else { 75 | return nil 76 | } 77 | } 78 | 79 | //nolint:gocritic // Using reference will trigger warning that p is a loop variable below 80 | func testTeardown(ctx context.Context, config providerConfig) error { 81 | if config.providerType == "github" { 82 | client := makeGitHubClient(ctx, &config) 83 | _, id, err := git.ParseGitAddress(config.url) 84 | if err != nil { 85 | return err 86 | } 87 | comp := strings.Split(id, "/") 88 | owner := comp[0] 89 | repo := comp[1] 90 | _, _, err = client.Repositories.UpdateBranchProtection( 91 | ctx, 92 | owner, 93 | repo, 94 | config.defaultBranch, 95 | &github.ProtectionRequest{}) 96 | return err 97 | } else { 98 | return nil 99 | } 100 | } 101 | 102 | func testRunCommand(t *testing.T, path string, verb string, args ...string) (string, error) { 103 | t.Helper() 104 | if image := os.Getenv("GITOPS_PROMOTION_IMAGE"); image != "" { 105 | binary := "docker" 106 | cmdline := []string{"run", "-v", fmt.Sprintf("%s:/workspace", path), image, verb} 107 | cmdline = append(cmdline, args...) 108 | t.Logf("Running docker %q", cmdline) 109 | outputBuffer, err := exec.Command(binary, cmdline...).CombinedOutput() 110 | output := strings.TrimSpace(string(outputBuffer)) 111 | if err != nil { 112 | return "", fmt.Errorf("%w: %s", err, output) 113 | } 114 | return output, nil 115 | } else { 116 | if os.Getenv("CI") != "" { 117 | return "", fmt.Errorf("CI is expected to test container. When Env var CI is set, please set GITOPS_PROMOTION_IMAGE.") 118 | } 119 | var cmdline = []string{"gitops-promotion", verb, "--sourcedir", path} 120 | cmdline = append(cmdline, args...) 121 | message, err := command.Run(context.TODO(), cmdline) 122 | return message, err 123 | } 124 | } 125 | 126 | func TestProviderE2E(t *testing.T) { 127 | for _, p := range providers { 128 | ctx := context.Background() 129 | err := testSetup(ctx, p) 130 | require.NoError(t, err) 131 | t.Run(string(p.providerType), func(t *testing.T) { 132 | if p.url == "" || p.password == "" { 133 | t.Skipf("Skipping test since url or password env var is not set") 134 | } 135 | path := t.TempDir() 136 | testCloneRepository(t, p.url, p.username, p.password, path, p.defaultBranch) 137 | 138 | now := time.Now() 139 | tag := now.Format("20060102150405") 140 | group := "testgroup" 141 | app := "testapp" 142 | 143 | ctx := context.Background() 144 | 145 | // Test DEV 146 | newCommandMsgDev, err := testRunCommand( 147 | t, 148 | path, 149 | "new", 150 | "--provider", string(p.providerType), 151 | "--token", p.password, 152 | "--group", group, 153 | "--app", app, 154 | "--tag", tag, 155 | ) 156 | require.NoError(t, err) 157 | 158 | promoteBranchName := fmt.Sprintf("promote/dev/%s-%s", group, app) 159 | require.Contains(t, newCommandMsgDev, fmt.Sprintf("created branch %s with pull request", promoteBranchName)) 160 | 161 | path = testCloneRepositoryAndValidateTag(t, p.url, p.username, p.password, p.defaultBranch, group, "dev", app, tag) 162 | 163 | repoDev := testGetRepository(t, path) 164 | revDev := testGetRepositoryHeadRevision(t, repoDev) 165 | 166 | testSetStatus(t, ctx, p.providerType, revDev, group, "dev", p.url, p.password, true) 167 | 168 | // Test QA 169 | promoteCommandMsgQa, err := testRunCommand( 170 | t, 171 | path, 172 | "promote", 173 | "--provider", string(p.providerType), 174 | "--token", p.password, 175 | ) 176 | require.NoError(t, err) 177 | 178 | promoteBranchName = fmt.Sprintf("promote/qa/%s-%s", group, app) 179 | require.Contains(t, promoteCommandMsgQa, fmt.Sprintf("created branch %s with pull request", promoteBranchName)) 180 | 181 | path = testCloneRepositoryAndValidateTag(t, p.url, p.username, p.password, promoteBranchName, group, "qa", app, tag) 182 | statusCommandMsgQa, err := testRunCommand( 183 | t, 184 | path, 185 | "status", 186 | "--provider", string(p.providerType), 187 | "--token", p.password, 188 | ) 189 | require.NoError(t, err) 190 | 191 | require.Contains(t, statusCommandMsgQa, "successful reconciliation for testgroup-dev") 192 | 193 | repoQa := testGetRepository(t, path) 194 | revQa := testGetRepositoryHeadRevision(t, repoQa) 195 | 196 | testMergePR(t, ctx, p.providerType, p.url, p.password, promoteBranchName, revQa) 197 | 198 | path = testCloneRepositoryAndValidateTag(t, p.url, p.username, p.password, p.defaultBranch, group, "qa", app, tag) 199 | 200 | repoMergedQa := testGetRepository(t, path) 201 | revMergedQa := testGetRepositoryHeadRevision(t, repoMergedQa) 202 | 203 | testSetStatus(t, ctx, p.providerType, revMergedQa, group, "qa", p.url, p.password, true) 204 | 205 | // Test PROD 206 | promoteCommandMsgProd, err := testRunCommand( 207 | t, 208 | path, 209 | "promote", 210 | "--provider", string(p.providerType), 211 | "--token", p.password, 212 | ) 213 | require.NoError(t, err) 214 | 215 | promoteBranchName = fmt.Sprintf("promote/prod/%s-%s", group, app) 216 | require.Contains(t, promoteCommandMsgProd, fmt.Sprintf("created branch %s with pull request", promoteBranchName)) 217 | 218 | path = testCloneRepositoryAndValidateTag(t, p.url, p.username, p.password, promoteBranchName, group, "prod", app, tag) 219 | statusCommandMsgProd, err := testRunCommand( 220 | t, 221 | path, 222 | "status", 223 | "--provider", string(p.providerType), 224 | "--token", p.password, 225 | ) 226 | require.NoError(t, err) 227 | 228 | require.Contains(t, statusCommandMsgProd, "successful reconciliation for testgroup-qa") 229 | 230 | repoProd := testGetRepository(t, path) 231 | revProd := testGetRepositoryHeadRevision(t, repoProd) 232 | 233 | testMergePR(t, ctx, p.providerType, p.url, p.password, promoteBranchName, revProd) 234 | 235 | path = testCloneRepositoryAndValidateTag(t, p.url, p.username, p.password, p.defaultBranch, group, "prod", app, tag) 236 | 237 | repoMergedProd := testGetRepository(t, path) 238 | revMergedProd := testGetRepositoryHeadRevision(t, repoMergedProd) 239 | 240 | testSetStatus(t, ctx, p.providerType, revMergedProd, group, "prod", p.url, p.password, true) 241 | 242 | // Test feature 243 | feature := "foobar" 244 | featureCommandMsg, err := testRunCommand( 245 | t, 246 | path, 247 | "feature", 248 | "--provider", string(p.providerType), 249 | "--token", p.password, 250 | "--group", group, 251 | "--app", app, 252 | "--tag", tag, 253 | "--feature", feature, 254 | ) 255 | require.NoError(t, err) 256 | 257 | featureBranchName := fmt.Sprintf("feature/%s-%s-%s", group, app, feature) 258 | require.Contains(t, featureCommandMsg, fmt.Sprintf("created branch %s with pull request", featureBranchName)) 259 | }) 260 | err = testTeardown(ctx, p) 261 | require.NoError(t, err) 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /tests/helpers_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/avast/retry-go" 11 | git2go "github.com/libgit2/git2go/v33" 12 | "github.com/stretchr/testify/require" 13 | "github.com/xenitab/gitops-promotion/pkg/git" 14 | ) 15 | 16 | func testCloneRepositoryAndValidateTag(t *testing.T, url, username, password, branchName, group, env, app, tag string) string { 17 | t.Helper() 18 | 19 | manifestPath := fmt.Sprintf("%s/%s/%s.yaml", group, env, app) 20 | var path string 21 | err := retry.Do(func() error { 22 | path = t.TempDir() 23 | testCloneRepository(t, url, username, password, path, branchName) 24 | fileName := fmt.Sprintf("%s/%s", path, manifestPath) 25 | 26 | content, err := os.ReadFile(fileName) 27 | if err != nil { 28 | return err 29 | } 30 | if strings.Contains(string(content), tag) { 31 | return nil 32 | } 33 | return fmt.Errorf("Was not able to pull the latest commit where %q contained tag: %s", manifestPath, tag) 34 | }) 35 | if err != nil { 36 | require.NoError(t, err) 37 | } 38 | return path 39 | } 40 | 41 | func testCloneRepository(t *testing.T, url, username, password, path, branchName string) { 42 | t.Helper() 43 | 44 | err := git.Clone(url, username, password, path, branchName) 45 | require.NoError(t, err) 46 | } 47 | 48 | func testGetRepository(t *testing.T, path string) *git2go.Repository { 49 | t.Helper() 50 | 51 | localRepo, err := git2go.OpenRepository(path) 52 | require.NoError(t, err) 53 | 54 | return localRepo 55 | } 56 | 57 | func testGetRepositoryHeadRevision(t *testing.T, repo *git2go.Repository) string { 58 | t.Helper() 59 | 60 | head, err := repo.Head() 61 | require.NoError(t, err) 62 | 63 | rev := head.Target().String() 64 | 65 | return rev 66 | } 67 | 68 | func testSetStatus( 69 | t *testing.T, 70 | ctx context.Context, 71 | providerType git.ProviderType, 72 | revision, 73 | group, 74 | env, 75 | url, 76 | token string, 77 | succeeded bool, 78 | ) { 79 | t.Helper() 80 | 81 | repo, err := git.NewGitProvider(ctx, providerType, url, token) 82 | require.NoError(t, err) 83 | 84 | err = repo.SetStatus(ctx, revision, group, env, succeeded) 85 | require.NoError(t, err) 86 | } 87 | 88 | func testMergePR(t *testing.T, ctx context.Context, providerType git.ProviderType, url, token, branch, revision string) { 89 | t.Helper() 90 | 91 | provider, err := git.NewGitProvider(ctx, providerType, url, token) 92 | require.NoError(t, err) 93 | 94 | pr, err := provider.GetPRWithBranch(ctx, branch, "main") 95 | require.NoError(t, err) 96 | 97 | err = provider.MergePR(ctx, pr.ID, revision) 98 | require.NoError(t, err) 99 | } 100 | --------------------------------------------------------------------------------