├── .frizbee.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── enhancement.yml ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── releaser.yml │ ├── test.yml │ └── trivy.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── Taskfile.yml ├── cmd ├── actions │ ├── actions.go │ └── list.go ├── image │ ├── image.go │ └── list.go ├── root.go └── version │ └── version.go ├── go.mod ├── go.sum ├── internal ├── cli │ ├── cli.go │ └── cli_test.go └── traverse │ ├── traverse.go │ └── traverse_test.go ├── main.go └── pkg ├── interfaces └── interfaces.go ├── replacer ├── actions │ ├── actions.go │ └── actions_test.go ├── image │ ├── image.go │ └── image_test.go ├── replacer.go └── replacer_test.go └── utils ├── config ├── config.go └── config_test.go ├── ghrest ├── ghrest.go └── ghrest_test.go └── store ├── cache.go └── cache_test.go /.frizbee.yml: -------------------------------------------------------------------------------- 1 | ghactions: 2 | exclude: 3 | # Exclude the SLSA GitHub Generator workflow. 4 | # See https://github.com/slsa-framework/slsa-github-generator/issues/2993 5 | - slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Report a bug 2 | description: Tell us about a bug or issue you may have identified. 3 | title: "Provide a general summary of the issue" 4 | labels: [bug] 5 | assignees: "-" 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: "To expedite our maintainers' efforts, please search for [duplicate or closed issues](https://github.com/stacklok/frizbee/issues?q=is%3Aissue+) and familiarize yourself with our [contributing guidelines](https://github.com/stacklok/frizbee/blob/main/CONTRIBUTING.md)." 10 | - type: textarea 11 | id: what-happened 12 | attributes: 13 | label: Describe the issue 14 | description: A clear and concise description of what the bug is. If applicable, add screenshots to illustrate the problem. 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: reproduce-steps 19 | attributes: 20 | label: To Reproduce 21 | description: Describe the steps to reproduce the behavior. 22 | - type: input 23 | id: version 24 | attributes: 25 | label: What version are you using? 26 | placeholder: "e.g., v5.1.0 or v4.5.2" 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Ask the community 3 | url: https://discord.com/invite/RkzVuTp3WK 4 | about: Ask and discuss questions with other Frizbee community members on Discord. 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest new or updated features to include in Frizbee. 3 | title: "Suggest a new feature" 4 | labels: [user-story] 5 | assignees: "-" 6 | body: 7 | - type: textarea 8 | id: description 9 | attributes: 10 | label: Please describe the enhancement 11 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...], this could be improved by [...], this would provide the ability to [...] 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: solution 16 | attributes: 17 | label: Solution Proposal 18 | description: A clear and concise description of what you want to happen. 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: considerations 23 | attributes: 24 | label: Describe alternatives you've considered 25 | description: A clear and concise description of any alternative solutions or features you've considered. 26 | - type: textarea 27 | id: context 28 | attributes: 29 | label: Additional context 30 | description: Add any other context about the feature request here. e.g. link to a Google doc if the design is being discussed there. 31 | - type: textarea 32 | id: acceptance 33 | attributes: 34 | label: Acceptance Criteria 35 | description: What does done look like? 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 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 | branches: [ "main" ] 19 | schedule: 20 | - cron: '36 6 * * 0' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners 29 | # Consider using larger runners for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 32 | permissions: 33 | actions: read 34 | contents: read 35 | security-events: write 36 | 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | language: [ 'go' ] 41 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 42 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 43 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 44 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 45 | 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v3 49 | 50 | - name: Setup Go 51 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v4 52 | with: 53 | go-version-file: ./go.mod 54 | 55 | # Initializes the CodeQL tools for scanning. 56 | - name: Initialize CodeQL 57 | uses: github/codeql-action/init@6fcbee77eeb7314eb10f949471de9856865a5f9b # v2 58 | with: 59 | languages: ${{ matrix.language }} 60 | # If you wish to specify custom queries, you can do so here or in a config file. 61 | # By default, queries listed here will override any specified in a config file. 62 | # Prefix the list here with "+" to use these queries and those in the config file. 63 | 64 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 65 | # queries: security-extended,security-and-quality 66 | 67 | 68 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 69 | # If this step fails, then you should remove it and run the build manually (see below) 70 | - name: Autobuild 71 | uses: github/codeql-action/autobuild@6fcbee77eeb7314eb10f949471de9856865a5f9b # v2 72 | 73 | # ℹ️ Command-line programs to run using the OS shell. 74 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 75 | 76 | # If the Autobuild fails above, remove it and uncomment the following three lines. 77 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 78 | 79 | # - run: | 80 | # echo "Run, Build Application using script" 81 | # ./location_of_script_within_repo/buildscript.sh 82 | 83 | - name: Perform CodeQL Analysis 84 | uses: github/codeql-action/analyze@6fcbee77eeb7314eb10f949471de9856865a5f9b # v2 85 | with: 86 | category: "/language:${{matrix.language}}" 87 | -------------------------------------------------------------------------------- /.github/workflows/releaser.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 Stacklok, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # This workflow compiles frizbee using a SLSA3 compliant 17 | # build and then verifies the provenance of the built artifacts. 18 | # It releases the following architectures: amd64, arm64, and armv7 on Linux, 19 | # Windows, and macOS. 20 | # The provenance file can be verified using https://github.com/slsa-framework/slsa-verifier. 21 | # For more information about SLSA and how it improves the supply-chain, visit slsa.dev. 22 | 23 | name: Release 24 | on: 25 | push: 26 | tags: 27 | - '*' 28 | 29 | permissions: 30 | contents: write 31 | 32 | jobs: 33 | ldflags_args: 34 | runs-on: ubuntu-latest 35 | outputs: 36 | commit-date: ${{ steps.ldflags.outputs.commit-date }} 37 | commit: ${{ steps.ldflags.outputs.commit }} 38 | version: ${{ steps.ldflags.outputs.version }} 39 | tree-state: ${{ steps.ldflags.outputs.tree-state }} 40 | steps: 41 | - id: checkout 42 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 43 | with: 44 | fetch-depth: 0 45 | - id: ldflags 46 | run: | 47 | echo "commit=$GITHUB_SHA" >> $GITHUB_OUTPUT 48 | echo "commit-date=$(git log --date=iso8601-strict -1 --pretty=%ct)" >> $GITHUB_OUTPUT 49 | echo "version=$(git describe --tags --always --dirty | cut -c2-)" >> $GITHUB_OUTPUT 50 | echo "tree-state=$(if git diff --quiet; then echo "clean"; else echo "dirty"; fi)" >> $GITHUB_OUTPUT 51 | release: 52 | needs: 53 | - ldflags_args 54 | name: Build and release 55 | outputs: 56 | hashes: ${{ steps.hash.outputs.hashes }} 57 | permissions: 58 | contents: write # To add assets to a release. 59 | id-token: write # To do keyless signing with cosign 60 | runs-on: ubuntu-latest 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 64 | with: 65 | fetch-depth: 0 66 | 67 | - name: Setup Go 68 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v4 69 | with: 70 | go-version-file: 'go.mod' 71 | cache: true 72 | 73 | - name: Install Syft 74 | uses: anchore/sbom-action/download-syft@61119d458adab75f756bc0b9e4bde25725f86a7a # v0.17.2 75 | 76 | - name: Install Cosign 77 | uses: sigstore/cosign-installer@4959ce089c160fddf62f7b42464195ba1a56d382 # v3.6.0 78 | 79 | - name: Run GoReleaser 80 | id: run-goreleaser 81 | uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v5 82 | with: 83 | distribution: goreleaser 84 | version: "~> v2" 85 | args: release --clean 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} 89 | VERSION: ${{ needs.ldflags_args.outputs.version }} 90 | COMMIT: ${{ needs.ldflags_args.outputs.commit }} 91 | COMMIT_DATE: ${{ needs.ldflags_args.outputs.commit-date }} 92 | TREE_STATE: ${{ needs.ldflags_args.outputs.tree-state }} 93 | 94 | - name: Generate subject 95 | id: hash 96 | env: 97 | ARTIFACTS: "${{ steps.run-goreleaser.outputs.artifacts }}" 98 | run: | 99 | set -euo pipefail 100 | hashes=$(echo $ARTIFACTS | jq --raw-output '.[] | {name, "digest": (.extra.Digest // .extra.Checksum)} | select(.digest) | {digest} + {name} | join(" ") | sub("^sha256:";"")' | base64 -w0) 101 | if test "$hashes" = ""; then # goreleaser < v1.13.0 102 | checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path') 103 | hashes=$(cat $checksum_file | base64 -w0) 104 | fi 105 | echo "hashes=$hashes" >> $GITHUB_OUTPUT 106 | 107 | provenance: 108 | name: Generate provenance (SLSA3) 109 | needs: 110 | - release 111 | permissions: 112 | actions: read # To read the workflow path. 113 | id-token: write # To sign the provenance. 114 | contents: write # To add assets to a release. 115 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 116 | with: 117 | base64-subjects: "${{ needs.release.outputs.hashes }}" 118 | upload-assets: true # upload to a new release 119 | 120 | verification: 121 | name: Verify provenance of assets (SLSA3) 122 | needs: 123 | - release 124 | - provenance 125 | runs-on: ubuntu-latest 126 | permissions: read-all 127 | steps: 128 | - name: Install the SLSA verifier 129 | uses: slsa-framework/slsa-verifier/actions/installer@3714a2a4684014deb874a0e737dffa0ee02dd647 # v2.6.0 130 | - name: Download assets 131 | env: 132 | GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 133 | CHECKSUMS: "${{ needs.release.outputs.hashes }}" 134 | ATT_FILE_NAME: "${{ needs.provenance.outputs.provenance-name }}" 135 | run: | 136 | set -euo pipefail 137 | checksums=$(echo "$CHECKSUMS" | base64 -d) 138 | while read -r line; do 139 | fn=$(echo $line | cut -d ' ' -f2) 140 | echo "Downloading $fn" 141 | gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$fn" 142 | done <<<"$checksums" 143 | gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$ATT_FILE_NAME" 144 | - name: Verify assets 145 | env: 146 | CHECKSUMS: "${{ needs.release.outputs.hashes }}" 147 | PROVENANCE: "${{ needs.provenance.outputs.provenance-name }}" 148 | run: | 149 | set -euo pipefail 150 | checksums=$(echo "$CHECKSUMS" | base64 -d) 151 | while read -r line; do 152 | fn=$(echo $line | cut -d ' ' -f2) 153 | echo "Verifying SLSA provenance for $fn" 154 | slsa-verifier verify-artifact --provenance-path "$PROVENANCE" \ 155 | --source-uri "github.com/$GITHUB_REPOSITORY" \ 156 | --source-tag "$GITHUB_REF_NAME" \ 157 | "$fn" 158 | done <<<"$checksums" 159 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 16 | 17 | - name: setup go 18 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v4 19 | with: 20 | go-version-file: ./go.mod 21 | 22 | - name: setup task 23 | uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v1 24 | with: 25 | repo-token: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | # Builds the binary into the bin/ directory 28 | - name: build 29 | run: |- 30 | task build 31 | 32 | - name: upload artifact 33 | uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 34 | with: 35 | name: frizbee 36 | path: bin/frizbee 37 | 38 | cover: 39 | name: Coverage 40 | permissions: 41 | contents: read 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 46 | 47 | - name: Set up Go 48 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v4 49 | with: 50 | go-version-file: 'go.mod' 51 | 52 | - name: setup task 53 | uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v1 54 | with: 55 | repo-token: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | - name: Run coverage 58 | run: task cover 59 | 60 | # Using gcov didn't seem to work for the coveralls app, so we convert it to lcov 61 | - name: Try converting to LCOV 62 | run: go run github.com/jandelgado/gcov2lcov@latest -infile=./coverage.out -outfile=./coverage.lcov 63 | 64 | - name: Coveralls GitHub Action 65 | uses: coverallsapp/github-action@643bc377ffa44ace6394b2b5d0d3950076de9f63 # v2.3.0 66 | 67 | lint: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: checkout 71 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 72 | 73 | - name: setup go 74 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v4 75 | with: 76 | go-version-file: ./go.mod 77 | 78 | - name: golangci-lint 79 | uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v3 80 | 81 | test: 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: checkout 85 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 86 | 87 | - name: setup go 88 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v4 89 | with: 90 | go-version-file: ./go.mod 91 | 92 | - name: setup task 93 | uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v1 94 | with: 95 | repo-token: ${{ secrets.GITHUB_TOKEN }} 96 | 97 | - name: test 98 | run: |- 99 | task test 100 | env: 101 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 102 | 103 | # Checks that the github workflows are valid using this same tool 104 | frizbee: 105 | runs-on: ubuntu-latest 106 | needs: 107 | - build 108 | steps: 109 | - name: checkout 110 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 111 | 112 | - name: download artifact 113 | uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 #v4.1.8 114 | with: 115 | name: frizbee 116 | path: bin/ 117 | 118 | - name: Make frizbee executable 119 | run: |- 120 | chmod +x bin/frizbee 121 | 122 | - name: Frizbee 123 | run: |- 124 | bin/frizbee actions --dry-run --error 125 | env: 126 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 127 | -------------------------------------------------------------------------------- /.github/workflows/trivy.yml: -------------------------------------------------------------------------------- 1 | name: Trivy Scan 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | trivy-code-security-scan: 8 | runs-on: ubuntu-latest 9 | name: Trivy 10 | permissions: 11 | contents: read 12 | security-events: write 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 16 | - name: Security Scan 17 | uses: aquasecurity/trivy-action@915b19bbe73b92a6cf82a1bc12b087c9a19a5fe2 # 0.28.0 18 | with: 19 | scan-type: 'fs' 20 | scanners: vuln,secret 21 | exit-code: 1 22 | ignore-unfixed: true 23 | format: 'sarif' 24 | output: 'trivy-results.sarif' 25 | env: 26 | TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db 27 | TRIVY_USERNAME: ${{ github.actor }} 28 | TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Upload Trivy scan results to GitHub Security tab 31 | uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3 32 | with: 33 | sarif_file: 'trivy-results.sarif' 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | bin/ 11 | dist/ 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 workspace file 23 | go.work 24 | 25 | .idea/ 26 | 27 | frizbee -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | issues-exit-code: 1 3 | timeout: 5m 4 | 5 | linters-settings: 6 | lll: 7 | line-length: 130 8 | gocyclo: 9 | min-complexity: 15 10 | gci: 11 | sections: 12 | - standard 13 | - default 14 | - prefix(github.com/stacklok/frizbee) 15 | revive: 16 | # see https://github.com/mgechev/revive#available-rules for details. 17 | ignore-generated-header: true 18 | severity: warning 19 | rules: 20 | - name: blank-imports 21 | severity: warning 22 | - name: context-as-argument 23 | - name: context-keys-type 24 | - name: duplicated-imports 25 | - name: error-naming 26 | # - name: error-strings #BDG: This was enabled for months, but it suddenly started working on 3/2/2022.. come to find out we have TONS of error messages starting with capital... disabling for now(ever?) 27 | - name: error-return 28 | - name: exported 29 | severity: error 30 | - name: if-return 31 | # - name: get-return // BDG: We have a lot of API endpoint handlers named like getFoos but write to response vs return... maybe later can figure that out 32 | - name: identical-branches 33 | - name: indent-error-flow 34 | - name: import-shadowing 35 | - name: package-comments 36 | - name: range-val-in-closure 37 | - name: range-val-address 38 | - name: redefines-builtin-id 39 | - name: struct-tag 40 | - name: unconditional-recursion 41 | - name: unnecessary-stmt 42 | - name: unreachable-code 43 | - name: unused-parameter 44 | - name: unused-receiver 45 | - name: unhandled-error 46 | disabled: true 47 | 48 | linters: 49 | disable-all: true 50 | enable: 51 | - asasalint 52 | - asciicheck 53 | - bidichk 54 | - bodyclose 55 | - contextcheck 56 | - copyloopvar 57 | - decorder 58 | - dogsled 59 | - dupl 60 | - errcheck 61 | - errname 62 | - exhaustive 63 | - forbidigo 64 | - forcetypeassert 65 | - gci 66 | - gochecknoglobals 67 | - gochecknoinits 68 | - gochecksumtype 69 | - goconst 70 | - gocyclo 71 | - gofmt 72 | - goprintffuncname 73 | - gosec 74 | - gosimple 75 | - gosimple 76 | - govet 77 | - importas 78 | - inamedparam 79 | - ineffassign 80 | - interfacebloat 81 | - lll 82 | - makezero 83 | - mirror 84 | - noctx 85 | - nosprintfhostport 86 | - paralleltest 87 | - perfsprint 88 | - promlinter 89 | - revive 90 | - staticcheck 91 | - tenv 92 | - thelper 93 | - tparallel 94 | - unparam 95 | - unused 96 | - usestdlibvars 97 | 98 | issues: 99 | exclude-use-default: false 100 | exclude-rules: 101 | - path: '(.+)_test\.go' 102 | linters: 103 | - lll 104 | 105 | output: 106 | formats: 107 | - format: colored-line-number 108 | print-issued-lines: true 109 | print-linter-name: true 110 | sort-results: true 111 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 3 | project_name: frizbee 4 | version: 2 5 | # This section defines the build matrix. 6 | builds: 7 | - env: 8 | - GO111MODULE=on 9 | - CGO_ENABLED=0 10 | flags: 11 | - -trimpath 12 | - -tags=netgo 13 | ldflags: 14 | - "-s -w" 15 | - "-X main.Version={{ .Env.VERSION }}" 16 | - "-X main.Commit={{ .Env.COMMIT }}" 17 | - "-X main.CommitDate={{ .Env.COMMIT_DATE }}" 18 | - "-X main.TreeState={{ .Env.TREE_STATE }}" 19 | - "-X github.com/stacklok/frizbee/internal/cli.CLIVersion={{ .Env.VERSION }}" 20 | goos: 21 | - linux 22 | - windows 23 | - darwin 24 | goarch: 25 | - amd64 26 | - arm64 27 | main: ./main.go 28 | # This section defines the release format. 29 | archives: 30 | - format: tar.gz # we can use binary, but it seems there's an issue where goreleaser skips the sboms 31 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 32 | format_overrides: 33 | - goos: windows 34 | format: zip 35 | # This section defines how to release to winget. 36 | # winget: 37 | # - name: frizbee 38 | # publisher: stacklok 39 | # license: Apache-2.0 40 | # license_url: "https://github.com/stacklok/frizbee/blob/main/LICENSE" 41 | # copyright: Stacklok, Inc. 42 | # homepage: https://stacklok.com 43 | # short_description: 'frizbee is a tool you may throw a tag at and it comes back with a checksum.' 44 | # publisher_support_url: "https://github.com/stacklok/frizbee/issues/new/choose" 45 | # package_identifier: "stacklok.frizbee" 46 | # url_template: "https://github.com/stacklok/frizbee/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 47 | # skip_upload: auto 48 | # release_notes: "{{.Changelog}}" 49 | # tags: 50 | # - golang 51 | # - cli 52 | # commit_author: 53 | # name: stacklokbot 54 | # email: info@stacklok.com 55 | # goamd64: v1 56 | # repository: 57 | # owner: stacklok 58 | # name: winget-pkgs 59 | # branch: "frizbee-{{.Version}}" 60 | # token: "{{ .Env.WINGET_GITHUB_TOKEN }}" 61 | # pull_request: 62 | # enabled: true 63 | # draft: false 64 | # base: 65 | # owner: microsoft 66 | # name: winget-pkgs 67 | # branch: master 68 | # This section defines whether we want to release the source code too. 69 | source: 70 | enabled: true 71 | # This section defines how to generate the changelog 72 | changelog: 73 | sort: asc 74 | use: github 75 | # This section defines for which artifact types to generate SBOMs. 76 | sboms: 77 | - artifacts: archive 78 | # This section defines the release policy. 79 | release: 80 | github: 81 | owner: stacklok 82 | name: frizbee 83 | # This section defines how and which artifacts we want to sign for the release. 84 | signs: 85 | - cmd: cosign 86 | args: 87 | - "sign-blob" 88 | - "--output-signature=${signature}" 89 | - "--output-certificate=${certificate}" 90 | - "${artifact}" 91 | - "--yes" # needed on cosign 2.0.0+ 92 | artifacts: archive 93 | output: true 94 | certificate: '{{ trimsuffix (trimsuffix .Env.artifact ".zip") ".tar.gz" }}.pem' 95 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to Frizbee 3 | First off, thank you for taking the time to contribute to Frizbee! :+1: :tada: Frizbee is released under the Apache 2.0 license. If you would like to contribute something or want to hack on the code, this document should help you get started. You can find some hints for starting development in Frizbee's [README](https://github.com/stacklok/frizbee/blob/main/README.md). 4 | 5 | ## Table of contents 6 | - [Code of Conduct](#code-of-conduct) 7 | - [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities) 8 | - [How to Contribute](#how-to-contribute) 9 | - [Sign the Contributor License Agreement](#sign-the-contributor-license-agreement) 10 | - [Using GitHub Issues](#using-github-issues) 11 | - [Not sure how to start contributing...](#not-sure-how-to-start-contributing) 12 | - [Pull Request Process](#pull-request-process) 13 | - [Contributing to docs](#contributing-to-docs) 14 | - [Commit Message Guidelines](#commit-message-guidelines) 15 | 16 | 17 | ## Code of Conduct 18 | This project adheres to the [Contributor Covenant](https://github.com/stacklok/frizbee/blob/main/CODE_OF_CONDUCT.md) code of conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to code-of-conduct@stacklok.dev. 19 | 20 | ## Reporting Security Vulnerabilities 21 | 22 | If you think you have found a security vulnerability in Frizbee please DO NOT disclose it publicly until we’ve had a chance to fix it. Please don’t report security vulnerabilities using GitHub issues; instead, please follow this [process](https://github.com/stacklok/frizbee/blob/main/SECURITY.md) 23 | 24 | ## How to Contribute 25 | 26 | ### Using GitHub Issues 27 | We use GitHub issues to track bugs and enhancements. If you have a general usage question, please ask in [Frizbee's discussion forum](https://discord.com/invite/RkzVuTp3WK). 28 | 29 | If you are reporting a bug, please help to speed up problem diagnosis by providing as much information as possible. Ideally, that would include a small sample project that reproduces the problem. 30 | 31 | ### Sign the Contributor License Agreement 32 | Before we accept a non-trivial patch or pull request, we will need you to sign the [Contributor License Agreement](https://cla-assistant.io/stacklok/frizbee). Signing the contributor’s agreement does not grant anyone commit rights to the main repository, but it does mean that we can accept your contributions, and you will get an author credit if we do. Active contributors might be asked to join the core team and given the ability to merge pull requests. 33 | 34 | ### Not sure how to start contributing... 35 | PRs to resolve existing issues are greatly appreciated and issues labeled as ["good first issue"](https://github.com/stacklok/frizbee/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) are a great place to start! 36 | 37 | ### Pull Request Process 38 | * Create an issue outlining the fix or feature. 39 | * Fork the Frizbee repository to your own GitHub account and clone it locally. 40 | * Hack on your changes. 41 | * Correctly format your commit messages, see [Commit Message Guidelines](#Commit-Message-Guidelines) below. 42 | * Open a PR by ensuring the title and its description reflect the content of the PR. 43 | * Ensure that CI passes, if it fails, fix the failures. 44 | * Every pull request requires a review from the core Frizbee team before merging. 45 | * Once approved, all of your commits will be squashed into a single commit with your PR title. 46 | 47 | ### Contributing to docs 48 | Follow [this guide](https://github.com/stacklok/frizbee/blob/main/docs/README.md) for instructions on building, running, and previewing Miner's documentation. 49 | 50 | ### Commit Message Guidelines 51 | We follow the commit formatting recommendations found on [Chris Beams' How to Write a Git Commit Message article](https://chris.beams.io/posts/git-commit/). 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Stacklok, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/stacklok/frizbee/assets/16540482/35034046-d962-475d-b8e2-67b7625f2a60) 2 | 3 | --- 4 | [![Coverage Status](https://coveralls.io/repos/github/stacklok/frizbee/badge.svg?branch=main)](https://coveralls.io/github/stacklok/frizbee?branch=main) | [![License: Apache 2.0](https://img.shields.io/badge/License-Apache2.0-brightgreen.svg)](https://opensource.org/licenses/Apache-2.0) | [![](https://dcbadge.vercel.app/api/server/RkzVuTp3WK?logo=discord&label=Discord&color=5865&style=flat)](https://discord.gg/RkzVuTp3WK) 5 | 6 | --- 7 | # Frizbee 8 | 9 | Frizbee is a tool you may throw a tag at and it comes back with a checksum. 10 | 11 | It's a command-line tool designed to provide checksums for GitHub Actions 12 | and container images based on tags. 13 | 14 | It also includes a set of libraries for working with tags and checksums. 15 | 16 | Frizbee is available as a GitHub Action: [frizbee-action](https://github.com/marketplace/actions/frizbee-action) 17 | 18 | ## Table of Contents 19 | 20 | - [Installation](#installation) 21 | - [Usage - CLI](#usage---cli) 22 | - [GitHub Actions](#github-actions) 23 | - [Container Images](#container-images) 24 | - [Usage - Library](#usage---library) 25 | - [GitHub Actions](#github-actions) 26 | - [Container Images](#container-images) 27 | - [Configuration](#configuration) 28 | - [Contributing](#contributing) 29 | - [License](#license) 30 | 31 | ## Installation 32 | 33 | To install Frizbee, you can use the following methods: 34 | 35 | ```bash 36 | # Using Go 37 | go install github.com/stacklok/frizbee@v0.1.2 38 | # add the path to your .bashrc 39 | export PATH=$PATH:$(go env GOPATH)/bin 40 | 41 | 42 | 43 | # Using Homebrew 44 | brew install frizbee 45 | 46 | # Using winget 47 | winget install stacklok.frizbee 48 | ``` 49 | 50 | ## Usage - CLI 51 | 52 | ### GitHub Actions 53 | 54 | Frizbee can be used to generate checksums for GitHub Actions. This is useful 55 | for verifying that the contents of a GitHub Action have not changed. 56 | 57 | To quickly replace the GitHub Action references for your project, you can use 58 | the `actions` command: 59 | 60 | ```bash 61 | frizbee actions path/to/your/repo/.github/workflows/ 62 | ``` 63 | 64 | This will write all the replacements to the files in the directory provided. 65 | 66 | Note that this command will only replace the `uses` field of the GitHub Action 67 | references. 68 | 69 | Note that this command supports dry-run mode, which will print the replacements 70 | to stdout instead of writing them to the files. 71 | 72 | It also supports exiting with a non-zero exit code if any replacements are found. 73 | This is handy for CI/CD pipelines. 74 | 75 | If you want to generate the replacement for a single GitHub Action, you can use the 76 | same command: 77 | 78 | ```bash 79 | frizbee actions metal-toolbox/container-push/.github/workflows/container-push.yml@main 80 | ``` 81 | 82 | This is useful if you're developing and want to quickly test the replacement. 83 | 84 | ### Container Images 85 | 86 | Frizbee can be used to generate checksums for container images. This is useful 87 | for verifying that the contents of a container image have not changed. This works 88 | for all yaml/yml and Dockerfile fies in the directory provided by the `-d` flag. 89 | 90 | To quickly replace the container image references for your project, you can use 91 | the `image` command: 92 | 93 | ```bash 94 | frizbee image path/to/your/yaml/files/ 95 | ``` 96 | 97 | To get the digest for a single image tag, you can use the same command: 98 | 99 | ```bash 100 | frizbee image ghcr.io/stacklok/minder/server:latest 101 | ``` 102 | 103 | This will print the image reference with the digest for the image tag provided. 104 | 105 | ## Usage - Library 106 | 107 | Frizbee can also be used as a library. The library provides a set of functions 108 | for working with tags and checksums. Here are a few examples of how you can use 109 | the library: 110 | 111 | ### GitHub Actions 112 | 113 | ```go 114 | // Create a new replacer 115 | r := replacer.NewGitHubActionsReplacer(config.DefaultConfig()) 116 | ... 117 | // Parse a single GitHub Action reference 118 | ret, err := r.ParseString(ctx, ghActionRef) 119 | ... 120 | // Parse all GitHub Actions workflow yaml files in a given directory 121 | res, err := r.ParsePath(ctx, dir) 122 | ... 123 | // Parse and replace all GitHub Actions references in the provided file system 124 | res, err := r.ParsePathInFS(ctx, bfs, base) 125 | ... 126 | // Parse a single yaml file referencing GitHub Actions 127 | res, err := r.ParseFile(ctx, fileHandler) 128 | ... 129 | // List all GitHub Actions referenced in the given directory 130 | res, err := r.ListPath(dir) 131 | ... 132 | // List all GitHub Actions referenced in the provided file system 133 | res, err := r.ListPathInFS(bfs, base) 134 | ... 135 | // List all GitHub Actions referenced in the provided file 136 | res, err := r.ListFile(fileHandler) 137 | ``` 138 | 139 | ### Container images 140 | 141 | ```go 142 | // Create a new replacer 143 | r := replacer.NewContainerImagesReplacer(config.DefaultConfig()) 144 | ... 145 | // Parse a single container image reference 146 | ret, err := r.ParseString(ctx, ghActionRef) 147 | ... 148 | // Parse all files containing container image references in a given directory 149 | res, err := r.ParsePath(ctx, dir) 150 | ... 151 | // Parse and replace all container image references in the provided file system 152 | res, err := r.ParsePathInFS(ctx, bfs, base) 153 | ... 154 | // Parse a single yaml file referencing container images 155 | res, err := r.ParseFile(ctx, fileHandler) 156 | ... 157 | // List all container images referenced in the given directory 158 | res, err := r.ListPath(dir) 159 | ... 160 | // List all container images referenced in the provided file system 161 | res, err := r.ListPathInFS(bfs, base) 162 | ... 163 | // List all container images referenced in the provided file 164 | res, err := r.ListFile(fileHandler) 165 | ``` 166 | 167 | ## Configuration 168 | 169 | Frizbee can be configured by setting up a `.frizbee.yml` file. 170 | You can configure Frizbee to skip processing certain actions, i.e. 171 | 172 | ```yml 173 | ghactions: 174 | exclude: 175 | # Exclude the SLSA GitHub Generator workflow. 176 | # See https://github.com/slsa-framework/slsa-github-generator/issues/2993 177 | - slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml 178 | 179 | ``` 180 | 181 | Similarly, you can exclude actions that are referenced using a particular branch: 182 | ```yml 183 | ghactions: 184 | exclude_branches: 185 | - main 186 | - master 187 | ``` 188 | By default, Frizbee will exclude all actions that are referencing `main` or `master`. 189 | 190 | You can also configure Frizbee to skip processing certain container images or certain tags: 191 | ```yml 192 | images: 193 | exclude_images: 194 | - busybox 195 | exclude_tags: 196 | - devel 197 | ``` 198 | By default, Frizbee will exclude the image named `scratch` and the tag `latest`. 199 | 200 | ## Contributing & Community 201 | 202 | Frizbee is maintained by a dedicated community of developers that want this open source project to benefit others and thrive. The main development of Frizbee is done in [Go](https://go.dev/). We welcome contributions of all types! Please see our [Contributing](./CONTRIBUTING.md) guide for more information on how you can help! 203 | 204 | If you have questions, or just want to chat with us - please use the #frizbee channel on our [Discord Server](https://discord.gg/stacklok). 205 | 206 | ## License 207 | 208 | Frizbee is licensed under the [Apache 2.0 License](./LICENSE). 209 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | The Frizbee team and community take security seriously! We appreciate your efforts to disclose your findings responsibly and will make every effort to acknowledge your contributions. 6 | 7 | To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/stacklok/frizbee/security/advisories/new) tab. 8 | 9 | The Frizbee team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will inform you of the progress toward a fix and public disclosure. Throughout this process, we may ask for additional information or guidance to ensure we have fixed the issue. 10 | 11 | Please report security bugs in third-party modules to the person or team maintaining the module. 12 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | 4 | tasks: 5 | default: 6 | silent: true 7 | cmds: 8 | - task -l 9 | 10 | test: 11 | desc: Run tests 12 | cmds: 13 | - go test -v ./... 14 | 15 | cover: 16 | desc: Run coverage 17 | cmds: 18 | - go test -coverprofile=coverage.out ./... 19 | 20 | build: 21 | desc: Build the frizbee binary 22 | cmds: 23 | - go build -ldflags="-X github.com/stacklok/frizbee/pkg/constants.CLIVersion=dev" -o ./bin/ ./... 24 | 25 | lint: 26 | desc: Run linter 27 | cmds: 28 | - golangci-lint run --timeout 5m0s --config .golangci.yml -------------------------------------------------------------------------------- /cmd/actions/actions.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package actions provides command-line utilities to work with GitHub Actions. 17 | package actions 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "os" 23 | "path/filepath" 24 | 25 | "github.com/spf13/cobra" 26 | 27 | "github.com/stacklok/frizbee/internal/cli" 28 | "github.com/stacklok/frizbee/pkg/interfaces" 29 | "github.com/stacklok/frizbee/pkg/replacer" 30 | "github.com/stacklok/frizbee/pkg/utils/config" 31 | ) 32 | 33 | // CmdGHActions represents the actions command 34 | func CmdGHActions() *cobra.Command { 35 | cmd := &cobra.Command{ 36 | Use: "actions", 37 | Short: "Replace tags in GitHub Actions workflows", 38 | Long: `This utility replaces tag or branch references in GitHub Actions workflows 39 | with the latest commit hash of the referenced tag or branch. 40 | 41 | Example: 42 | 43 | $ frizbee actions <.github/workflows> or 44 | 45 | This will replace all tag or branch references in all GitHub Actions workflows 46 | for the given directory. Supports both directories and single references. 47 | 48 | ` + cli.TokenHelpText + "\n", 49 | Aliases: []string{"ghactions"}, // backwards compatibility 50 | RunE: replaceCmd, 51 | SilenceUsage: true, 52 | Args: cobra.MaximumNArgs(1), 53 | } 54 | 55 | // flags 56 | cli.DeclareFrizbeeFlags(cmd, false) 57 | 58 | // sub-commands 59 | cmd.AddCommand(CmdList()) 60 | 61 | return cmd 62 | } 63 | 64 | // nolint:errcheck 65 | func replaceCmd(cmd *cobra.Command, args []string) error { 66 | // Set the default directory if not provided 67 | pathOrRef := ".github/workflows" 68 | if len(args) > 0 { 69 | pathOrRef = args[0] 70 | } 71 | 72 | // Extract the CLI flags from the cobra command 73 | cliFlags, err := cli.NewHelper(cmd) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | // Set up the config 79 | cfg, err := config.FromCommand(cmd) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | // Create a new replacer 85 | r := replacer.NewGitHubActionsReplacer(cfg). 86 | WithUserRegex(cliFlags.Regex). 87 | WithGitHubClientFromToken(os.Getenv(cli.GitHubTokenEnvKey)) 88 | 89 | if cli.IsPath(pathOrRef) { 90 | dir := filepath.Clean(pathOrRef) 91 | // Replace the tags in the given directory 92 | res, err := r.ParsePath(cmd.Context(), dir) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | if err := cliFlags.CheckModified(res.Modified); err != nil { 98 | return err 99 | } 100 | 101 | // Process the output files 102 | return cliFlags.ProcessOutput(dir, res.Processed, res.Modified) 103 | } 104 | // Replace the passed reference 105 | res, err := r.ParseString(cmd.Context(), pathOrRef) 106 | if err != nil { 107 | if errors.Is(err, interfaces.ErrReferenceSkipped) { 108 | fmt.Fprintln(cmd.OutOrStdout(), pathOrRef) // nolint:errcheck 109 | return nil 110 | } 111 | return err 112 | } 113 | fmt.Fprintf(cmd.OutOrStdout(), "%s@%s\n", res.Name, res.Ref) // nolint:errcheck 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /cmd/actions/list.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package actions 17 | 18 | import ( 19 | "encoding/json" 20 | "errors" 21 | "fmt" 22 | "os" 23 | "path/filepath" 24 | "strconv" 25 | 26 | "github.com/olekukonko/tablewriter" 27 | "github.com/spf13/cobra" 28 | 29 | "github.com/stacklok/frizbee/internal/cli" 30 | "github.com/stacklok/frizbee/pkg/replacer" 31 | "github.com/stacklok/frizbee/pkg/utils/config" 32 | ) 33 | 34 | // CmdList represents the one sub-command 35 | func CmdList() *cobra.Command { 36 | cmd := &cobra.Command{ 37 | Use: "list", 38 | Short: "Lists the used github actions", 39 | Long: `This utility lists all the github actions used in the workflows 40 | 41 | Example: 42 | frizbee action list .github/workflows 43 | `, 44 | Aliases: []string{"ls"}, 45 | RunE: list, 46 | SilenceUsage: true, 47 | Args: cobra.MaximumNArgs(1), 48 | } 49 | 50 | cli.DeclareFrizbeeFlags(cmd, true) 51 | 52 | return cmd 53 | } 54 | 55 | func list(cmd *cobra.Command, args []string) error { 56 | // Set the default directory if not provided 57 | dir := ".github/workflows" 58 | if len(args) > 0 { 59 | dir = args[0] 60 | } 61 | 62 | dir = filepath.Clean(dir) 63 | if !cli.IsPath(dir) { 64 | return errors.New("the provided argument is not a path") 65 | } 66 | // Extract the CLI flags from the cobra command 67 | cliFlags, err := cli.NewHelper(cmd) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | // Set up the config 73 | cfg, err := config.FromCommand(cmd) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | // Create a new replacer 79 | r := replacer.NewGitHubActionsReplacer(cfg). 80 | WithUserRegex(cliFlags.Regex). 81 | WithGitHubClientFromToken(os.Getenv(cli.GitHubTokenEnvKey)) 82 | 83 | // List the references in the directory 84 | res, err := r.ListPath(dir) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | output := cmd.Flag("output").Value.String() 90 | switch output { 91 | case "json": 92 | jsonBytes, err := json.MarshalIndent(res.Entities, "", " ") 93 | if err != nil { 94 | return err 95 | } 96 | jsonString := string(jsonBytes) 97 | 98 | fmt.Fprintln(cmd.OutOrStdout(), jsonString) // nolint:errcheck 99 | return nil 100 | case "table": 101 | table := tablewriter.NewWriter(cmd.OutOrStdout()) 102 | table.SetHeader([]string{"No", "Type", "Name", "Ref"}) 103 | for i, a := range res.Entities { 104 | table.Append([]string{strconv.Itoa(i + 1), a.Type, a.Name, a.Ref}) 105 | } 106 | table.Render() 107 | return nil 108 | default: 109 | return fmt.Errorf("unknown output format: %s", output) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /cmd/image/image.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package image provides command-line utilities to work with container images. 17 | package image 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "path/filepath" 23 | 24 | "github.com/spf13/cobra" 25 | 26 | "github.com/stacklok/frizbee/internal/cli" 27 | "github.com/stacklok/frizbee/pkg/interfaces" 28 | "github.com/stacklok/frizbee/pkg/replacer" 29 | "github.com/stacklok/frizbee/pkg/utils/config" 30 | ) 31 | 32 | // CmdContainerImage represents the containers command 33 | func CmdContainerImage() *cobra.Command { 34 | cmd := &cobra.Command{ 35 | Use: "image", 36 | Short: "Replace container image references with checksums", 37 | Long: `This utility replaces tag or branch references in yaml/yml files 38 | with the latest commit hash of the referenced tag or branch. 39 | 40 | Example: 41 | 42 | $ frizbee image or 43 | 44 | This will replace all tag or branch references in all yaml files for the given directory. 45 | `, 46 | RunE: replaceCmd, 47 | SilenceUsage: true, 48 | Aliases: []string{"containerimage", "dockercompose", "compose"}, // backwards compatibility 49 | Args: cobra.ExactArgs(1), 50 | } 51 | 52 | // flags 53 | cli.DeclareFrizbeeFlags(cmd, false) 54 | 55 | // sub-commands 56 | cmd.AddCommand(CmdList()) 57 | 58 | return cmd 59 | } 60 | 61 | func replaceCmd(cmd *cobra.Command, args []string) error { 62 | // Extract the CLI flags from the cobra command 63 | cliFlags, err := cli.NewHelper(cmd) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | // Set up the config 69 | cfg, err := config.FromCommand(cmd) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | // Create a new replacer 75 | r := replacer.NewContainerImagesReplacer(cfg). 76 | WithUserRegex(cliFlags.Regex) 77 | 78 | if cli.IsPath(args[0]) { 79 | dir := filepath.Clean(args[0]) 80 | // Replace the tags in the directory 81 | res, err := r.ParsePath(cmd.Context(), dir) 82 | if err != nil { 83 | return err 84 | } 85 | // Process the output files 86 | return cliFlags.ProcessOutput(dir, res.Processed, res.Modified) 87 | } 88 | // Replace the passed reference 89 | res, err := r.ParseString(cmd.Context(), args[0]) 90 | if err != nil { 91 | if errors.Is(err, interfaces.ErrReferenceSkipped) { 92 | fmt.Fprintln(cmd.OutOrStdout(), args[0]) // nolint:errcheck 93 | return nil 94 | } 95 | return err 96 | } 97 | fmt.Fprintf(cmd.OutOrStdout(), "%s@%s\n", res.Name, res.Ref) // nolint:errcheck 98 | return nil 99 | 100 | } 101 | -------------------------------------------------------------------------------- /cmd/image/list.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package image 17 | 18 | import ( 19 | "encoding/json" 20 | "errors" 21 | "fmt" 22 | "path/filepath" 23 | "strconv" 24 | 25 | "github.com/olekukonko/tablewriter" 26 | "github.com/spf13/cobra" 27 | 28 | "github.com/stacklok/frizbee/internal/cli" 29 | "github.com/stacklok/frizbee/pkg/replacer" 30 | "github.com/stacklok/frizbee/pkg/utils/config" 31 | ) 32 | 33 | // CmdList represents the one sub-command 34 | func CmdList() *cobra.Command { 35 | cmd := &cobra.Command{ 36 | Use: "list", 37 | Short: "Lists the used container images", 38 | Long: `This utility lists all container images used in the files in the directory 39 | 40 | Example: 41 | frizbee image list 42 | `, 43 | Aliases: []string{"ls"}, 44 | RunE: list, 45 | SilenceUsage: true, 46 | Args: cobra.ExactArgs(1), 47 | } 48 | 49 | cli.DeclareFrizbeeFlags(cmd, true) 50 | 51 | return cmd 52 | } 53 | 54 | func list(cmd *cobra.Command, args []string) error { 55 | dir := filepath.Clean(args[0]) 56 | if !cli.IsPath(dir) { 57 | return errors.New("the provided argument is not a path") 58 | } 59 | // Extract the CLI flags from the cobra command 60 | cliFlags, err := cli.NewHelper(cmd) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | // Set up the config 66 | cfg, err := config.FromCommand(cmd) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | // Create a new replacer 72 | r := replacer.NewContainerImagesReplacer(cfg). 73 | WithUserRegex(cliFlags.Regex) 74 | 75 | // List the references in the directory 76 | res, err := r.ListPath(dir) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | output := cmd.Flag("output").Value.String() 82 | switch output { 83 | case "json": 84 | jsonBytes, err := json.MarshalIndent(res.Entities, "", " ") 85 | if err != nil { 86 | return err 87 | } 88 | jsonString := string(jsonBytes) 89 | fmt.Fprintln(cmd.OutOrStdout(), jsonString) // nolint:errcheck 90 | return nil 91 | case "table": 92 | table := tablewriter.NewWriter(cmd.OutOrStdout()) 93 | table.SetHeader([]string{"No", "Type", "Name", "Ref"}) 94 | for i, a := range res.Entities { 95 | table.Append([]string{strconv.Itoa(i + 1), a.Type, a.Name, a.Ref}) 96 | } 97 | table.Render() 98 | return nil 99 | default: 100 | return fmt.Errorf("unknown output format: %s", output) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2023 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package cmd provides the frizbee command line interface. 17 | package cmd 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os" 23 | 24 | "github.com/spf13/cobra" 25 | 26 | "github.com/stacklok/frizbee/cmd/actions" 27 | "github.com/stacklok/frizbee/cmd/image" 28 | "github.com/stacklok/frizbee/cmd/version" 29 | "github.com/stacklok/frizbee/pkg/utils/config" 30 | ) 31 | 32 | // Execute runs the root command. 33 | func Execute() { 34 | var rootCmd = &cobra.Command{ 35 | Use: "frizbee", 36 | Short: "frizbee is a tool you may throw a tag at and it comes back with a checksum", 37 | PersistentPreRunE: prerun, 38 | } 39 | 40 | rootCmd.PersistentFlags().StringP("config", "c", ".frizbee.yml", "config file (default is .frizbee.yml)") 41 | 42 | rootCmd.AddCommand(actions.CmdGHActions()) 43 | rootCmd.AddCommand(image.CmdContainerImage()) 44 | rootCmd.AddCommand(version.CmdVersion()) 45 | 46 | if err := rootCmd.ExecuteContext(context.Background()); err != nil { 47 | os.Exit(1) 48 | } 49 | } 50 | 51 | func prerun(cmd *cobra.Command, _ []string) error { 52 | ctx := cmd.Context() 53 | 54 | cfg, err := readConfig(cmd) 55 | if err != nil { 56 | return fmt.Errorf("failed to read config: %w", err) 57 | } 58 | 59 | ctx = context.WithValue(ctx, config.ContextConfigKey, cfg) 60 | 61 | cmd.SetContext(ctx) 62 | 63 | return nil 64 | } 65 | 66 | func readConfig(cmd *cobra.Command) (*config.Config, error) { 67 | configFile, err := cmd.Flags().GetString("config") 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to get config file: %w", err) 70 | } 71 | 72 | return config.ParseConfigFile(configFile) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/version/version.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package version adds a version command. 17 | package version 18 | 19 | import ( 20 | "github.com/spf13/cobra" 21 | 22 | "github.com/stacklok/frizbee/internal/cli" 23 | ) 24 | 25 | // CmdVersion is the Cobra command for the version command. 26 | func CmdVersion() *cobra.Command { 27 | return &cobra.Command{ 28 | Use: "version", 29 | Short: "Print frizbee CLI version", 30 | Long: "The frizbee version command prints the version of the frizbee CLI.", 31 | Run: func(cmd *cobra.Command, _ []string) { 32 | cmd.Println(cli.VerboseCLIVersion) 33 | }, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stacklok/frizbee 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/deckarep/golang-set/v2 v2.8.0 9 | github.com/go-git/go-billy/v5 v5.6.2 10 | github.com/google/go-containerregistry v0.20.5 11 | github.com/google/go-github/v66 v66.0.0 12 | github.com/moby/buildkit v0.22.0 13 | github.com/olekukonko/tablewriter v0.0.5 14 | github.com/puzpuzpuz/xsync v1.5.2 15 | github.com/spf13/cobra v1.9.1 16 | github.com/stretchr/testify v1.10.0 17 | golang.org/x/sync v0.14.0 18 | gopkg.in/h2non/gock.v1 v1.1.2 19 | gopkg.in/yaml.v3 v3.0.1 20 | ) 21 | 22 | require ( 23 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect 24 | github.com/containerd/typeurl/v2 v2.2.3 // indirect 25 | github.com/cyphar/filepath-securejoin v0.3.6 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/docker/cli v28.1.1+incompatible // indirect 28 | github.com/docker/distribution v2.8.3+incompatible // indirect 29 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 30 | github.com/gogo/protobuf v1.3.2 // indirect 31 | github.com/google/go-querystring v1.1.0 // indirect 32 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect 33 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 34 | github.com/klauspost/compress v1.18.0 // indirect 35 | github.com/mattn/go-runewidth v0.0.9 // indirect 36 | github.com/mitchellh/go-homedir v1.1.0 // indirect 37 | github.com/opencontainers/go-digest v1.0.0 // indirect 38 | github.com/opencontainers/image-spec v1.1.1 // indirect 39 | github.com/pkg/errors v0.9.1 // indirect 40 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 41 | github.com/pmezard/go-difflib v1.0.0 // indirect 42 | github.com/sirupsen/logrus v1.9.3 // indirect 43 | github.com/spf13/pflag v1.0.6 // indirect 44 | github.com/vbatts/tar-split v0.12.1 // indirect 45 | golang.org/x/sys v0.33.0 // indirect 46 | google.golang.org/protobuf v1.36.3 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= 2 | github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= 3 | github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= 4 | github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 6 | github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= 7 | github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= 12 | github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= 13 | github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k= 14 | github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 15 | github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= 16 | github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 17 | github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= 18 | github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= 19 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 20 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 21 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 22 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 23 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 24 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 25 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 26 | github.com/google/go-containerregistry v0.20.5 h1:4RnlYcDs5hoA++CeFjlbZ/U9Yp1EuWr+UhhTyYQjOP0= 27 | github.com/google/go-containerregistry v0.20.5/go.mod h1:Q14vdOOzug02bwnhMkZKD4e30pDaD9W65qzXpyzF49E= 28 | github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= 29 | github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= 30 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 31 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 32 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= 33 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= 34 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 35 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 36 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 37 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 38 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 39 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 40 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 41 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 42 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 43 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 44 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 45 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 46 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 47 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 48 | github.com/moby/buildkit v0.22.0 h1:aWN06w1YGSVN1XfeZbj2ZbgY+zi5xDAjEFI8Cy9fTjA= 49 | github.com/moby/buildkit v0.22.0/go.mod h1:j4pP5hxiTWcz7xuTK2cyxQislHl/N2WWHzOy43DlLJw= 50 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= 51 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 52 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 53 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 54 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 55 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 56 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 57 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 58 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 59 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 60 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 61 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 62 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= 63 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 64 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 65 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/puzpuzpuz/xsync v1.5.2 h1:yRAP4wqSOZG+/4pxJ08fPTwrfL0IzE/LKQ/cw509qGY= 67 | github.com/puzpuzpuz/xsync v1.5.2/go.mod h1:K98BYhX3k1dQ2M63t1YNVDanbwUPmBCAhNmVrrxfiGg= 68 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 69 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 70 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 71 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 72 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 73 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 74 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 75 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 76 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 77 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 78 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 79 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 80 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 81 | github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= 82 | github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= 83 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 84 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 85 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 86 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 87 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 88 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 89 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 90 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 91 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 92 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 93 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 94 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 95 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 96 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 97 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 98 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 99 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 100 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 101 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 102 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 103 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 104 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 105 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 108 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 109 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 110 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 111 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 112 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 113 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 114 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 115 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 116 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 117 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 118 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 119 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 120 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 121 | google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= 122 | google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 123 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 124 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 125 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 126 | gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= 127 | gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= 128 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 129 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 130 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 131 | gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= 132 | gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= 133 | -------------------------------------------------------------------------------- /internal/cli/cli.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package cli provides utilities to work with the command-line interface. 17 | package cli 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "io" 23 | "os" 24 | "path/filepath" 25 | "runtime/debug" 26 | "strings" 27 | "text/template" 28 | 29 | "github.com/go-git/go-billy/v5/osfs" 30 | "github.com/spf13/cobra" 31 | ) 32 | 33 | const ( 34 | // UserAgent is the user agent string used by frizbee. 35 | // 36 | // TODO (jaosorior): Add version information to this. 37 | UserAgent = "frizbee" 38 | // GitHubTokenEnvKey is the environment variable key for the GitHub token 39 | //nolint:gosec // This is not a hardcoded credential 40 | GitHubTokenEnvKey = "GITHUB_TOKEN" 41 | 42 | // TokenHelpText is the help text for the GitHub token 43 | TokenHelpText = "NOTE: It's recommended to set the " + GitHubTokenEnvKey + 44 | " environment variable given that GitHub has tighter rate limits on anonymous calls." 45 | verboseTemplate = `Version: {{ .Version }} 46 | Go Version: {{.GoVersion}} 47 | Git Commit: {{.Commit}} 48 | Commit Date: {{.Time}} 49 | OS/Arch: {{.OS}}/{{.Arch}} 50 | Dirty: {{.Modified}} 51 | ` 52 | ) 53 | 54 | // Helper is a common struct for implementing a CLI command that replaces 55 | // files. 56 | type Helper struct { 57 | DryRun bool 58 | Quiet bool 59 | ErrOnModified bool 60 | Regex string 61 | Cmd *cobra.Command 62 | } 63 | 64 | type versionInfo struct { 65 | Version string 66 | GoVersion string 67 | Time string 68 | Commit string 69 | OS string 70 | Arch string 71 | Modified bool 72 | } 73 | 74 | var ( 75 | // CLIVersion is the version of the frizbee CLI. 76 | // nolint: gochecknoglobals 77 | CLIVersion = "dev" 78 | // VerboseCLIVersion is the verbose version of the frizbee CLI. 79 | // nolint: gochecknoglobals 80 | VerboseCLIVersion = "" 81 | ) 82 | 83 | // nolint:gochecknoinits 84 | func init() { 85 | buildinfo, ok := debug.ReadBuildInfo() 86 | if !ok { 87 | return 88 | } 89 | 90 | var vinfo versionInfo 91 | vinfo.Version = CLIVersion 92 | vinfo.GoVersion = buildinfo.GoVersion 93 | 94 | for _, kv := range buildinfo.Settings { 95 | switch kv.Key { 96 | case "vcs.time": 97 | vinfo.Time = kv.Value 98 | case "vcs.revision": 99 | vinfo.Commit = kv.Value 100 | case "vcs.modified": 101 | vinfo.Modified = kv.Value == "true" 102 | case "GOOS": 103 | vinfo.OS = kv.Value 104 | case "GOARCH": 105 | vinfo.Arch = kv.Value 106 | } 107 | } 108 | VerboseCLIVersion = vinfo.String() 109 | } 110 | 111 | func (vvs *versionInfo) String() string { 112 | stringBuilder := &strings.Builder{} 113 | tmpl := template.Must(template.New("version").Parse(verboseTemplate)) 114 | err := tmpl.Execute(stringBuilder, vvs) 115 | if err != nil { 116 | panic(err) 117 | } 118 | return stringBuilder.String() 119 | } 120 | 121 | // NewHelper creates a new CLI Helper struct. 122 | func NewHelper(cmd *cobra.Command) (*Helper, error) { 123 | dryRun, err := cmd.Flags().GetBool("dry-run") 124 | if err != nil { 125 | return nil, fmt.Errorf("failed to get dry-run flag: %w", err) 126 | } 127 | errOnModified, err := cmd.Flags().GetBool("error") 128 | if err != nil { 129 | return nil, fmt.Errorf("failed to get error flag: %w", err) 130 | } 131 | quiet, err := cmd.Flags().GetBool("quiet") 132 | if err != nil { 133 | return nil, fmt.Errorf("failed to get quiet flag: %w", err) 134 | } 135 | regex, err := cmd.Flags().GetString("regex") 136 | if err != nil { 137 | return nil, fmt.Errorf("failed to get regex flag: %w", err) 138 | } 139 | 140 | return &Helper{ 141 | Cmd: cmd, 142 | DryRun: dryRun, 143 | ErrOnModified: errOnModified, 144 | Quiet: quiet, 145 | Regex: regex, 146 | }, nil 147 | } 148 | 149 | // DeclareFrizbeeFlags declares the flags common to all replacer commands. 150 | func DeclareFrizbeeFlags(cmd *cobra.Command, enableOutput bool) { 151 | cmd.Flags().BoolP("dry-run", "n", false, "don't modify files") 152 | cmd.Flags().BoolP("quiet", "q", false, "don't print anything") 153 | cmd.Flags().BoolP("error", "e", false, "exit with error code if any file is modified") 154 | cmd.Flags().StringP("regex", "r", "", "regex to match artifact references") 155 | cmd.Flags().StringP("platform", "p", "", "platform to match artifact references, e.g. linux/amd64") 156 | if enableOutput { 157 | cmd.Flags().StringP("output", "o", "table", "output format. Can be 'json' or 'table'") 158 | } 159 | } 160 | 161 | // Logf logs the given message to the given command's stderr if the command is 162 | // not quiet. 163 | func (r *Helper) Logf(format string, args ...interface{}) { 164 | if !r.Quiet { 165 | fmt.Fprintf(r.Cmd.ErrOrStderr(), format, args...) // nolint:errcheck 166 | } 167 | } 168 | 169 | // CheckModified checks if any files were modified and returns an error if there were. 170 | func (r *Helper) CheckModified(modified map[string]string) error { 171 | if len(modified) > 0 && r.ErrOnModified { 172 | if !r.Quiet { 173 | for path := range modified { 174 | r.Logf("Modified: %s\n", path) 175 | } 176 | } 177 | 178 | return errors.New("files were modified") 179 | } 180 | 181 | return nil 182 | } 183 | 184 | // ProcessOutput processes the given output files. 185 | // If the command is quiet, the output is discarded. 186 | // If the command is a dry run, the output is written to the command's stdout. 187 | // Otherwise, the output is written to the given filesystem. 188 | func (r *Helper) ProcessOutput(path string, processed []string, modified map[string]string) error { 189 | basedir := filepath.Dir(path) 190 | bfs := osfs.New(basedir, osfs.WithBoundOS()) 191 | var out io.Writer 192 | for _, path := range processed { 193 | if !r.Quiet { 194 | r.Logf("Processed: %s\n", path) 195 | } 196 | } 197 | for path, content := range modified { 198 | if r.Quiet { 199 | out = io.Discard 200 | } else if r.DryRun { 201 | out = r.Cmd.OutOrStdout() 202 | } else { 203 | f, err := bfs.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644) 204 | if err != nil { 205 | return fmt.Errorf("failed to open file %s: %w", path, err) 206 | } 207 | 208 | defer func() { 209 | if err := f.Close(); err != nil { 210 | fmt.Fprintf(r.Cmd.ErrOrStderr(), "failed to close file %s: %v", path, err) // nolint:errcheck 211 | } 212 | }() 213 | 214 | out = f 215 | } 216 | if !r.Quiet { 217 | r.Logf("Modified: %s\n", path) 218 | } 219 | _, err := fmt.Fprintf(out, "%s", content) 220 | if err != nil { 221 | return fmt.Errorf("failed to write to file %s: %w", path, err) 222 | } 223 | } 224 | 225 | return nil 226 | } 227 | 228 | // IsPath returns true if the given path is a file or directory. 229 | func IsPath(pathOrRef string) bool { 230 | _, err := os.Stat(pathOrRef) 231 | return err == nil 232 | } 233 | -------------------------------------------------------------------------------- /internal/cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/go-git/go-billy/v5" 9 | "github.com/go-git/go-billy/v5/memfs" 10 | "github.com/spf13/cobra" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestNewHelper(t *testing.T) { 15 | t.Parallel() 16 | 17 | testCases := []struct { 18 | name string 19 | cmdArgs []string 20 | expected *Helper 21 | expectedError bool 22 | }{ 23 | { 24 | name: "ValidFlags", 25 | cmdArgs: []string{"--dry-run", "--quiet", "--error", "--regex", "test"}, 26 | expected: &Helper{ 27 | DryRun: true, 28 | Quiet: true, 29 | ErrOnModified: true, 30 | Regex: "test", 31 | }, 32 | expectedError: false, 33 | }, 34 | { 35 | name: "MissingFlags", 36 | cmdArgs: []string{}, 37 | expected: &Helper{}, 38 | expectedError: false, 39 | }, 40 | { 41 | name: "InvalidFlags", 42 | cmdArgs: []string{"--nonexistent"}, 43 | expected: nil, 44 | expectedError: true, 45 | }, 46 | } 47 | 48 | for _, tt := range testCases { 49 | t.Run(tt.name, func(t *testing.T) { 50 | t.Parallel() 51 | 52 | cmd := &cobra.Command{} 53 | DeclareFrizbeeFlags(cmd, true) 54 | cmd.SetArgs(tt.cmdArgs) 55 | 56 | if tt.expectedError { 57 | assert.Error(t, cmd.Execute()) 58 | return 59 | } 60 | 61 | assert.NoError(t, cmd.Execute()) 62 | 63 | helper, err := NewHelper(cmd) 64 | if tt.expectedError { 65 | assert.Error(t, err) 66 | assert.Nil(t, helper) 67 | } else { 68 | assert.NoError(t, err) 69 | assert.NotNil(t, helper) 70 | assert.Equal(t, tt.expected.DryRun, helper.DryRun) 71 | assert.Equal(t, tt.expected.Quiet, helper.Quiet) 72 | assert.Equal(t, tt.expected.ErrOnModified, helper.ErrOnModified) 73 | assert.Equal(t, tt.expected.Regex, helper.Regex) 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func TestCheckModified(t *testing.T) { 80 | t.Parallel() 81 | 82 | testCases := []struct { 83 | name string 84 | helper *Helper 85 | modified map[string]string 86 | expectOutput string 87 | expectError bool 88 | }{ 89 | { 90 | name: "NoFilesModified", 91 | helper: &Helper{ 92 | ErrOnModified: true, 93 | Quiet: false, 94 | Cmd: &cobra.Command{}, 95 | }, 96 | modified: map[string]string{}, 97 | expectOutput: "", 98 | expectError: false, 99 | }, 100 | { 101 | name: "FilesModifiedWithErrorFlag", 102 | helper: &Helper{ 103 | ErrOnModified: true, 104 | Quiet: false, 105 | Cmd: &cobra.Command{}, 106 | }, 107 | modified: map[string]string{ 108 | "file1.txt": "modified content", 109 | "file2.txt": "another modified content", 110 | }, 111 | expectOutput: "Modified: file1.txt\nModified: file2.txt", 112 | expectError: true, 113 | }, 114 | { 115 | name: "FilesModifiedWithoutErrorFlag", 116 | helper: &Helper{ 117 | ErrOnModified: false, 118 | Quiet: false, 119 | Cmd: &cobra.Command{}, 120 | }, 121 | modified: map[string]string{ 122 | "file1.txt": "modified content", 123 | }, 124 | expectOutput: "", 125 | expectError: false, 126 | }, 127 | { 128 | name: "FilesModifiedWithErrorFlagAndQuiet", 129 | helper: &Helper{ 130 | ErrOnModified: true, 131 | Quiet: true, 132 | Cmd: &cobra.Command{}, 133 | }, 134 | modified: map[string]string{ 135 | "file1.txt": "modified content", 136 | }, 137 | expectOutput: "", 138 | expectError: true, 139 | }, 140 | } 141 | 142 | for _, tt := range testCases { 143 | t.Run(tt.name, func(t *testing.T) { 144 | t.Parallel() 145 | 146 | // Set up command error output 147 | var output strings.Builder 148 | tt.helper.Cmd.SetErr(&output) 149 | 150 | // Call the CheckModified method 151 | err := tt.helper.CheckModified(tt.modified) 152 | 153 | // Check if error status matches expectation 154 | if tt.expectError { 155 | assert.Error(t, err) 156 | assert.Equal(t, "files were modified", err.Error()) 157 | } else { 158 | assert.NoError(t, err) 159 | } 160 | 161 | // Check if output contains expected strings 162 | if tt.expectOutput != "" { 163 | // We can't guarantee the order of map iteration, so we check that 164 | // the output contains each expected line 165 | for path := range tt.modified { 166 | if !tt.helper.Quiet { 167 | assert.Contains(t, output.String(), "Modified: "+path) 168 | } 169 | } 170 | } 171 | }) 172 | } 173 | } 174 | 175 | func TestProcessOutput(t *testing.T) { 176 | t.Parallel() 177 | 178 | testCases := []struct { 179 | name string 180 | helper *Helper 181 | path string 182 | processed []string 183 | modified map[string]string 184 | expectedOutput string 185 | expectError bool 186 | }{ 187 | { 188 | name: "QuietMode", 189 | helper: &Helper{ 190 | Quiet: true, 191 | Cmd: &cobra.Command{}, 192 | }, 193 | path: "test/path", 194 | processed: []string{"file1.txt", "file2.txt"}, 195 | modified: map[string]string{"file1.txt": "new content"}, 196 | expectedOutput: "", 197 | expectError: false, 198 | }, 199 | { 200 | name: "DryRunMode", 201 | helper: &Helper{ 202 | Quiet: false, 203 | DryRun: true, 204 | Cmd: &cobra.Command{}, 205 | }, 206 | path: "test/path", 207 | processed: []string{"file1.txt"}, 208 | modified: map[string]string{"file1.txt": "new content"}, 209 | expectedOutput: "Processed: file1.txt\nModified: file1.txt\nnew content", 210 | expectError: false, 211 | }, 212 | { 213 | name: "ErrorOpeningFile", 214 | helper: &Helper{ 215 | Quiet: false, 216 | Cmd: &cobra.Command{}, 217 | }, 218 | path: "invalid/path", 219 | modified: map[string]string{"invalid/path": "new content"}, 220 | expectedOutput: "", 221 | expectError: true, 222 | }, 223 | } 224 | 225 | for _, tt := range testCases { 226 | t.Run(tt.name, func(t *testing.T) { 227 | t.Parallel() 228 | 229 | // Set up command output 230 | var output strings.Builder 231 | tt.helper.Cmd.SetOut(&output) 232 | tt.helper.Cmd.SetErr(&output) 233 | 234 | // Create in-memory filesystem and add files 235 | fs := memfs.New() 236 | for path, content := range tt.modified { 237 | dir := filepath.Join(tt.path, filepath.Dir(path)) 238 | assert.NoError(t, fs.MkdirAll(dir, 0755)) 239 | file, err := fs.Create(filepath.Join(tt.path, path)) 240 | if err == nil { 241 | _, _ = file.Write([]byte(content)) 242 | assert.NoError(t, file.Close()) 243 | } 244 | } 245 | 246 | // Process the output using the in-memory filesystem 247 | err := tt.helper.ProcessOutput(tt.path, tt.processed, tt.modified) 248 | if tt.expectError { 249 | assert.Error(t, err) 250 | } else { 251 | assert.NoError(t, err) 252 | assert.Contains(t, output.String(), tt.expectedOutput) 253 | } 254 | }) 255 | } 256 | } 257 | 258 | func TestIsPath(t *testing.T) { 259 | t.Parallel() 260 | 261 | testCases := []struct { 262 | name string 263 | setup func(fs billy.Filesystem) 264 | path string 265 | expected bool 266 | }{ 267 | { 268 | name: "ExistingFile", 269 | setup: func(fs billy.Filesystem) { 270 | file, _ := fs.Create("testfile.txt") 271 | assert.NoError(t, file.Close()) 272 | }, 273 | path: "testfile.txt", 274 | expected: true, 275 | }, 276 | { 277 | name: "NonExistentFile", 278 | setup: func(_ billy.Filesystem) {}, 279 | path: "nonexistent.txt", 280 | expected: false, 281 | }, 282 | { 283 | name: "ExistingDirectory", 284 | setup: func(fs billy.Filesystem) { 285 | assert.NoError(t, fs.MkdirAll("testdir", 0755)) 286 | }, 287 | path: "testdir", 288 | expected: true, 289 | }, 290 | { 291 | name: "NonExistentDirectory", 292 | setup: func(_ billy.Filesystem) {}, 293 | path: "nonexistentdir", 294 | expected: false, 295 | }, 296 | } 297 | 298 | for _, tt := range testCases { 299 | t.Run(tt.name, func(t *testing.T) { 300 | t.Parallel() 301 | 302 | // Use in-memory filesystem for testing 303 | fs := memfs.New() 304 | tt.setup(fs) 305 | 306 | // Check if the path exists in the in-memory filesystem 307 | _, err := fs.Stat(tt.path) 308 | result := err == nil 309 | 310 | assert.Equal(t, tt.expected, result) 311 | }) 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /internal/traverse/traverse.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package traverse provides utilities to traverse directories. 17 | package traverse 18 | 19 | import ( 20 | "fmt" 21 | "io/fs" 22 | "os" 23 | "path/filepath" 24 | "strings" 25 | 26 | "github.com/go-git/go-billy/v5" 27 | ) 28 | 29 | // GhwFunc is a function that gets called with each file in a GitHub Actions workflow 30 | // directory. It receives the path to the file. 31 | type GhwFunc func(path string) error 32 | 33 | // FuncTraverse is a function that gets called with each file in a directory. 34 | type FuncTraverse func(path string, info fs.FileInfo) error 35 | 36 | // YamlDockerfiles traverses all yaml/yml in the given directory 37 | // and calls the given function with each workflow. 38 | func YamlDockerfiles(bfs billy.Filesystem, base string, fun GhwFunc) error { 39 | return Traverse(bfs, base, func(path string, info fs.FileInfo) error { 40 | if !isYAMLOrDockerfile(info) { 41 | return nil 42 | } 43 | 44 | if err := fun(path); err != nil { 45 | return fmt.Errorf("failed to process file %s: %w", path, err) 46 | } 47 | 48 | return nil 49 | }) 50 | } 51 | 52 | // Traverse traverses the given directory and calls the given function with each file. 53 | func Traverse(bfs billy.Filesystem, base string, fun FuncTraverse) error { 54 | return Walk(bfs, base, func(path string, info fs.FileInfo, err error) error { 55 | if err != nil { 56 | return nil 57 | } 58 | 59 | return fun(path, info) 60 | }) 61 | } 62 | 63 | // isYAMLOrDockerfile returns true if the given file is a YAML or Dockerfile. 64 | func isYAMLOrDockerfile(info fs.FileInfo) bool { 65 | // Skip if not a file 66 | if info.IsDir() { 67 | return false 68 | } 69 | 70 | // Filter out files that are not yml, yaml or dockerfiles 71 | if strings.HasSuffix(info.Name(), ".yml") || strings.HasSuffix(info.Name(), ".yaml") || 72 | strings.Contains(strings.ToLower(info.Name()), "dockerfile") { 73 | return true 74 | } 75 | 76 | return false 77 | } 78 | 79 | // walk recursively descends path, calling walkFn 80 | // adapted from https://golang.org/src/path/filepath/path.go 81 | func walk(bfs billy.Filesystem, path string, info os.FileInfo, walkFn filepath.WalkFunc) error { 82 | if !info.IsDir() { 83 | return walkFn(path, info, nil) 84 | } 85 | 86 | names, err := readDirNames(bfs, path) 87 | err1 := walkFn(path, info, err) 88 | // If err != nil, walk can't walk into this directory. 89 | // err1 != nil means walkFn want walk to skip this directory or stop walking. 90 | // Therefore, if one of err and err1 isn't nil, walk will return. 91 | if err != nil || err1 != nil { 92 | // The caller's behavior is controlled by the return value, which is decided 93 | // by walkFn. walkFn may ignore err and return nil. 94 | // If walkFn returns SkipDir, it will be handled by the caller. 95 | // So walk should return whatever walkFn returns. 96 | return err1 97 | } 98 | 99 | for _, name := range names { 100 | filename := filepath.Join(path, name) 101 | fileInfo, err := bfs.Lstat(filename) 102 | if err != nil { 103 | if err := walkFn(filename, fileInfo, err); err != nil && err != filepath.SkipDir { 104 | return err 105 | } 106 | } else { 107 | err = walk(bfs, filename, fileInfo, walkFn) 108 | if err != nil { 109 | if !fileInfo.IsDir() || err != filepath.SkipDir { 110 | return err 111 | } 112 | } 113 | } 114 | } 115 | return nil 116 | } 117 | 118 | // Walk walks the file tree rooted at root, calling fn for each file or 119 | // directory in the tree, including root. All errors that arise visiting files 120 | // and directories are filtered by fn: see the WalkFunc documentation for 121 | // details. 122 | // 123 | // The files are walked in lexical order, which makes the output deterministic 124 | // but requires Walk to read an entire directory into memory before proceeding 125 | // to walk that directory. Walk does not follow symbolic links. 126 | // 127 | // Function adapted from https://github.com/golang/go/blob/3b770f2ccb1fa6fecc22ea822a19447b10b70c5c/src/path/filepath/path.go#L500 128 | func Walk(bfs billy.Filesystem, root string, walkFn filepath.WalkFunc) error { 129 | info, err := bfs.Lstat(root) 130 | if err != nil { 131 | err = walkFn(root, nil, err) 132 | } else { 133 | err = walk(bfs, root, info, walkFn) 134 | } 135 | 136 | if err == filepath.SkipDir { 137 | return nil 138 | } 139 | 140 | return err 141 | } 142 | 143 | func readDirNames(bfs billy.Filesystem, dir string) ([]string, error) { 144 | files, err := bfs.ReadDir(dir) 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | var names []string 150 | for _, file := range files { 151 | names = append(names, file.Name()) 152 | } 153 | 154 | return names, nil 155 | } 156 | -------------------------------------------------------------------------------- /internal/traverse/traverse_test.go: -------------------------------------------------------------------------------- 1 | package traverse 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/go-git/go-billy/v5/memfs" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestYamlDockerfiles(t *testing.T) { 14 | t.Parallel() 15 | 16 | testCases := []struct { 17 | name string 18 | fsContent map[string]string 19 | baseDir string 20 | expected []string 21 | expectError bool 22 | }{ 23 | { 24 | name: "NoYAMLOrDockerfile", 25 | fsContent: map[string]string{ 26 | "base/file.txt": "content", 27 | }, 28 | baseDir: "base", 29 | expected: []string{}, 30 | expectError: false, 31 | }, 32 | { 33 | name: "WithYAMLFiles", 34 | fsContent: map[string]string{ 35 | "base/file.yml": "content", 36 | "base/file.yaml": "content", 37 | "base/not_included.txt": "content", 38 | }, 39 | baseDir: "base", 40 | expected: []string{ 41 | "base/file.yml", 42 | "base/file.yaml", 43 | }, 44 | expectError: false, 45 | }, 46 | { 47 | name: "WithDockerfiles", 48 | fsContent: map[string]string{ 49 | "base/Dockerfile": "content", 50 | "base/nested/dockerfile": "content", 51 | "base/not_included.txt": "content", 52 | }, 53 | baseDir: "base", 54 | expected: []string{ 55 | "base/Dockerfile", 56 | "base/nested/dockerfile", 57 | }, 58 | expectError: false, 59 | }, 60 | { 61 | name: "MixedFiles", 62 | fsContent: map[string]string{ 63 | "base/file.yml": "content", 64 | "base/Dockerfile": "content", 65 | "base/nested/file.yaml": "content", 66 | "base/nested/dockerfile": "content", 67 | "base/not_included.txt": "content", 68 | }, 69 | baseDir: "base", 70 | expected: []string{ 71 | "base/file.yml", 72 | "base/Dockerfile", 73 | "base/nested/file.yaml", 74 | "base/nested/dockerfile", 75 | }, 76 | expectError: false, 77 | }, 78 | { 79 | name: "ErrorInProcessingFile", 80 | fsContent: map[string]string{ 81 | "base/file.yml": "content", 82 | }, 83 | baseDir: "base", 84 | expectError: true, 85 | }, 86 | } 87 | 88 | for _, tt := range testCases { 89 | t.Run(tt.name, func(t *testing.T) { 90 | t.Parallel() 91 | 92 | fs := memfs.New() 93 | for name, content := range tt.fsContent { 94 | f, _ := fs.Create(name) 95 | _, _ = f.Write([]byte(content)) 96 | assert.NoError(t, f.Close()) 97 | } 98 | 99 | var processedFiles []string 100 | err := YamlDockerfiles(fs, tt.baseDir, func(path string) error { 101 | if tt.expectError { 102 | return errors.New("error in processing file") 103 | } 104 | processedFiles = append(processedFiles, path) 105 | return nil 106 | }) 107 | 108 | if tt.expectError { 109 | assert.Error(t, err) 110 | } else { 111 | assert.NoError(t, err) 112 | assert.ElementsMatch(t, tt.expected, processedFiles) 113 | } 114 | }) 115 | } 116 | } 117 | 118 | func TestTraverse(t *testing.T) { 119 | t.Parallel() 120 | 121 | testCases := []struct { 122 | name string 123 | fsContent map[string]string 124 | baseDir string 125 | expected []string 126 | expectError bool 127 | }{ 128 | { 129 | name: "TraverseFiles", 130 | fsContent: map[string]string{ 131 | "base/file1.txt": "content", 132 | "base/file2.txt": "content", 133 | "base/nested/file": "content", 134 | }, 135 | baseDir: "base", 136 | expected: []string{ 137 | "base", 138 | "base/file1.txt", 139 | "base/file2.txt", 140 | "base/nested", 141 | "base/nested/file", 142 | }, 143 | expectError: false, 144 | }, 145 | { 146 | name: "TraverseWithError", 147 | fsContent: map[string]string{ 148 | "base/file.txt": "content", 149 | }, 150 | baseDir: "base", 151 | expectError: true, 152 | }, 153 | } 154 | 155 | for _, tt := range testCases { 156 | t.Run(tt.name, func(t *testing.T) { 157 | t.Parallel() 158 | 159 | fs := memfs.New() 160 | for name, content := range tt.fsContent { 161 | f, _ := fs.Create(name) 162 | _, _ = f.Write([]byte(content)) 163 | assert.NoError(t, f.Close()) 164 | } 165 | 166 | var processedFiles []string 167 | err := Traverse(fs, tt.baseDir, func(path string, _ os.FileInfo) error { 168 | if tt.expectError { 169 | return errors.New("error in traversing file") 170 | } 171 | processedFiles = append(processedFiles, path) 172 | return nil 173 | }) 174 | 175 | if tt.expectError { 176 | assert.Error(t, err) 177 | } else { 178 | assert.NoError(t, err) 179 | assert.ElementsMatch(t, tt.expected, processedFiles) 180 | } 181 | }) 182 | } 183 | } 184 | 185 | func TestIsYAMLOrDockerfile(t *testing.T) { 186 | t.Parallel() 187 | 188 | testCases := []struct { 189 | name string 190 | fileName string 191 | isDir bool 192 | expected bool 193 | }{ 194 | { 195 | name: "YAMLFile", 196 | fileName: "config.yaml", 197 | isDir: false, 198 | expected: true, 199 | }, 200 | { 201 | name: "YMLFile", 202 | fileName: "config.yml", 203 | isDir: false, 204 | expected: true, 205 | }, 206 | { 207 | name: "Dockerfile", 208 | fileName: "Dockerfile", 209 | isDir: false, 210 | expected: true, 211 | }, 212 | { 213 | name: "dockerfile", 214 | fileName: "dockerfile", 215 | isDir: false, 216 | expected: true, 217 | }, 218 | { 219 | name: "NonYAMLOrDockerfile", 220 | fileName: "config.txt", 221 | isDir: false, 222 | expected: false, 223 | }, 224 | { 225 | name: "Directory", 226 | fileName: "config", 227 | isDir: true, 228 | expected: false, 229 | }, 230 | } 231 | 232 | for _, tt := range testCases { 233 | t.Run(tt.name, func(t *testing.T) { 234 | t.Parallel() 235 | 236 | info := &fileInfoMock{ 237 | name: tt.fileName, 238 | dir: tt.isDir, 239 | } 240 | 241 | result := isYAMLOrDockerfile(info) 242 | assert.Equal(t, tt.expected, result) 243 | }) 244 | } 245 | } 246 | 247 | // fileInfoMock is a mock implementation of os.FileInfo for testing. 248 | type fileInfoMock struct { 249 | name string 250 | dir bool 251 | } 252 | 253 | func (f *fileInfoMock) Name() string { return f.name } 254 | func (_ *fileInfoMock) Size() int64 { return 0 } 255 | func (_ *fileInfoMock) Mode() os.FileMode { return 0 } 256 | func (_ *fileInfoMock) ModTime() time.Time { return time.Time{} } 257 | func (f *fileInfoMock) IsDir() bool { return f.dir } 258 | func (_ *fileInfoMock) Sys() interface{} { return nil } 259 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 NAME HERE 3 | */ 4 | package main 5 | 6 | import "github.com/stacklok/frizbee/cmd" 7 | 8 | func main() { 9 | cmd.Execute() 10 | } 11 | -------------------------------------------------------------------------------- /pkg/interfaces/interfaces.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package interfaces provides interfaces for the frizbee package. 17 | package interfaces 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "net/http" 23 | 24 | "github.com/stacklok/frizbee/pkg/utils/config" 25 | "github.com/stacklok/frizbee/pkg/utils/store" 26 | ) 27 | 28 | var ( 29 | // ErrReferenceSkipped is returned when the reference is skipped. 30 | ErrReferenceSkipped = errors.New("reference skipped") 31 | ) 32 | 33 | // EntityRef represents an action reference. 34 | type EntityRef struct { 35 | Name string `json:"name"` 36 | Ref string `json:"ref"` 37 | Type string `json:"type"` 38 | Tag string `json:"tag"` 39 | Prefix string `json:"prefix"` 40 | } 41 | 42 | // Parser is an interface to replace references with digests 43 | type Parser interface { 44 | SetCache(cache store.RefCacher) 45 | SetRegex(regex string) 46 | GetRegex() string 47 | Replace(ctx context.Context, matchedLine string, restIf REST, cfg config.Config) (*EntityRef, error) 48 | ConvertToEntityRef(reference string) (*EntityRef, error) 49 | } 50 | 51 | // The REST interface allows to wrap clients to talk to remotes 52 | // When talking to GitHub, wrap a github client to provide this interface 53 | type REST interface { 54 | // NewRequest creates an HTTP request. 55 | NewRequest(method, url string, body any) (*http.Request, error) 56 | // Do executes an HTTP request. 57 | Do(ctx context.Context, req *http.Request) (*http.Response, error) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/replacer/actions/actions.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package actions provides utilities to work with GitHub Actions. 17 | package actions 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "errors" 23 | "fmt" 24 | "net/http" 25 | "net/url" 26 | "slices" 27 | "strings" 28 | 29 | "github.com/google/go-github/v66/github" 30 | 31 | "github.com/stacklok/frizbee/pkg/interfaces" 32 | "github.com/stacklok/frizbee/pkg/replacer/image" 33 | "github.com/stacklok/frizbee/pkg/utils/config" 34 | "github.com/stacklok/frizbee/pkg/utils/store" 35 | ) 36 | 37 | const ( 38 | prefixUses = "uses: " 39 | prefixDocker = "docker://" 40 | // GitHubActionsRegex is regular expression pattern to match GitHub Actions usage 41 | GitHubActionsRegex = `uses:\s*[^\s]+/[^\s]+@[^\s]+|uses:\s*docker://[^\s]+:[^\s]+` 42 | // ReferenceType is the type of the reference 43 | ReferenceType = "action" 44 | ) 45 | 46 | var ( 47 | // ErrInvalidAction is returned when parsing the action fails. 48 | ErrInvalidAction = errors.New("invalid action") 49 | // ErrInvalidActionReference is returned when parsing the action reference fails. 50 | ErrInvalidActionReference = errors.New("action reference is not a tag nor branch") 51 | ) 52 | 53 | // Parser is a struct to replace action references with digests 54 | type Parser struct { 55 | regex string 56 | cache store.RefCacher 57 | } 58 | 59 | // New creates a new Parser 60 | func New() *Parser { 61 | return &Parser{ 62 | regex: GitHubActionsRegex, 63 | cache: store.NewRefCacher(), 64 | } 65 | } 66 | 67 | // SetCache returns the regular expression pattern to match GitHub Actions usage 68 | func (p *Parser) SetCache(cache store.RefCacher) { 69 | p.cache = cache 70 | } 71 | 72 | // SetRegex returns the regular expression pattern to match GitHub Actions usage 73 | func (p *Parser) SetRegex(regex string) { 74 | p.regex = regex 75 | } 76 | 77 | // GetRegex returns the regular expression pattern to match GitHub Actions usage 78 | func (p *Parser) GetRegex() string { 79 | return p.regex 80 | } 81 | 82 | // Replace replaces the action reference with the digest 83 | func (p *Parser) Replace( 84 | ctx context.Context, 85 | matchedLine string, 86 | restIf interfaces.REST, 87 | cfg config.Config, 88 | ) (*interfaces.EntityRef, error) { 89 | var err error 90 | var actionRef *interfaces.EntityRef 91 | hasUsesPrefix := false 92 | 93 | // Trim the uses prefix 94 | if strings.HasPrefix(matchedLine, prefixUses) { 95 | matchedLine = strings.TrimPrefix(matchedLine, prefixUses) 96 | hasUsesPrefix = true 97 | } 98 | // Determine if the action reference has a docker prefix 99 | if strings.HasPrefix(matchedLine, prefixDocker) { 100 | actionRef, err = p.replaceDocker(ctx, matchedLine, restIf, cfg) 101 | } else { 102 | actionRef, err = p.replaceAction(ctx, matchedLine, restIf, cfg) 103 | } 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | // Add back the uses prefix 109 | if hasUsesPrefix { 110 | actionRef.Prefix = fmt.Sprintf("%s%s", prefixUses, actionRef.Prefix) 111 | } 112 | 113 | // Return the new action reference 114 | return actionRef, nil 115 | } 116 | 117 | func (p *Parser) replaceAction( 118 | ctx context.Context, 119 | matchedLine string, 120 | restIf interfaces.REST, 121 | cfg config.Config, 122 | ) (*interfaces.EntityRef, error) { 123 | 124 | // If the value is a local path or should be excluded, skip it 125 | if isLocal(matchedLine) || shouldExclude(&cfg.GHActions, matchedLine) { 126 | return nil, fmt.Errorf("%w: %s", interfaces.ErrReferenceSkipped, matchedLine) 127 | } 128 | 129 | // Parse the action reference 130 | act, ref, err := ParseActionReference(matchedLine) 131 | if err != nil { 132 | return nil, fmt.Errorf("failed to parse action reference '%s': %w", matchedLine, err) 133 | } 134 | 135 | // Check if the parsed reference should be excluded 136 | if shouldExclude(&cfg.GHActions, act) { 137 | return nil, fmt.Errorf("%w: %s", interfaces.ErrReferenceSkipped, matchedLine) 138 | } 139 | var sum string 140 | 141 | // Check if we have a cache 142 | if p.cache != nil { 143 | // Check if we have a cached value 144 | if val, ok := p.cache.Load(matchedLine); ok { 145 | sum = val 146 | } else { 147 | // Get the checksum for the action reference 148 | sum, err = GetChecksum(ctx, cfg.GHActions, restIf, act, ref) 149 | if err != nil { 150 | return nil, fmt.Errorf("failed to get checksum for action '%s': %w", matchedLine, err) 151 | } 152 | // Store the checksum in the cache 153 | p.cache.Store(matchedLine, sum) 154 | } 155 | } else { 156 | // Get the checksum for the action reference 157 | sum, err = GetChecksum(ctx, cfg.GHActions, restIf, act, ref) 158 | if err != nil { 159 | return nil, fmt.Errorf("failed to get checksum for action '%s': %w", matchedLine, err) 160 | } 161 | } 162 | 163 | // Compare the digest with the reference and return the original reference if they already match 164 | if ref == sum { 165 | return nil, fmt.Errorf("image already referenced by digest: %s %w", matchedLine, interfaces.ErrReferenceSkipped) 166 | } 167 | 168 | return &interfaces.EntityRef{ 169 | Name: act, 170 | Ref: sum, 171 | Type: ReferenceType, 172 | Tag: ref, 173 | }, nil 174 | } 175 | 176 | func (p *Parser) replaceDocker( 177 | ctx context.Context, 178 | matchedLine string, 179 | _ interfaces.REST, 180 | cfg config.Config, 181 | ) (*interfaces.EntityRef, error) { 182 | // Trim the docker prefix 183 | trimmedRef := strings.TrimPrefix(matchedLine, prefixDocker) 184 | 185 | // If the value is a local path or should be excluded, skip it 186 | if isLocal(trimmedRef) || shouldExclude(&cfg.GHActions, trimmedRef) { 187 | return nil, fmt.Errorf("%w: %s", interfaces.ErrReferenceSkipped, matchedLine) 188 | } 189 | 190 | // Get the digest of the docker:// image reference 191 | actionRef, err := image.GetImageDigestFromRef(ctx, trimmedRef, cfg.Platform, p.cache) 192 | if err != nil { 193 | return nil, err 194 | } 195 | 196 | // Check if the parsed reference should be excluded 197 | if shouldExclude(&cfg.GHActions, actionRef.Name) { 198 | return nil, fmt.Errorf("%w: %s", interfaces.ErrReferenceSkipped, matchedLine) 199 | } 200 | 201 | // Add back the docker prefix 202 | if strings.HasPrefix(matchedLine, prefixDocker) { 203 | actionRef.Prefix = fmt.Sprintf("%s%s", prefixDocker, actionRef.Prefix) 204 | } 205 | 206 | return actionRef, nil 207 | } 208 | 209 | // ConvertToEntityRef converts an action reference to an EntityRef 210 | func (_ *Parser) ConvertToEntityRef(reference string) (*interfaces.EntityRef, error) { 211 | reference = strings.TrimPrefix(reference, prefixUses) 212 | refType := ReferenceType 213 | separator := "@" 214 | // Update the separator in case this is a docker reference with a digest 215 | if strings.Contains(reference, prefixDocker) { 216 | reference = strings.TrimPrefix(reference, prefixDocker) 217 | if !strings.Contains(reference, separator) && strings.Contains(reference, ":") { 218 | separator = ":" 219 | } 220 | refType = image.ReferenceType 221 | } 222 | frags := strings.Split(reference, separator) 223 | if len(frags) != 2 { 224 | return nil, fmt.Errorf("invalid action reference: %s", reference) 225 | } 226 | 227 | return &interfaces.EntityRef{ 228 | Name: frags[0], 229 | Ref: frags[1], 230 | Type: refType, 231 | }, nil 232 | } 233 | 234 | // isLocal returns true if the input is a local path. 235 | func isLocal(input string) bool { 236 | return strings.HasPrefix(input, "./") || strings.HasPrefix(input, "../") 237 | } 238 | 239 | func shouldExclude(cfg *config.GHActions, input string) bool { 240 | for _, e := range cfg.Exclude { 241 | if e == input { 242 | return true 243 | } 244 | } 245 | return false 246 | } 247 | 248 | // ParseActionReference parses an action reference into action and reference. 249 | func ParseActionReference(input string) (action string, reference string, err error) { 250 | frags := strings.Split(input, "@") 251 | if len(frags) != 2 { 252 | return "", "", fmt.Errorf("invalid action reference: %s", input) 253 | } 254 | 255 | return frags[0], frags[1], nil 256 | } 257 | 258 | // GetChecksum returns the checksum for a given action and tag. 259 | func GetChecksum(ctx context.Context, cfg config.GHActions, restIf interfaces.REST, action, ref string) (string, error) { 260 | owner, repo, err := parseActionFragments(action) 261 | if err != nil { 262 | return "", err 263 | } 264 | 265 | // Check if we're using a checksum 266 | if isChecksum(ref) { 267 | return ref, nil 268 | } 269 | 270 | res, err := getCheckSumForTag(ctx, restIf, owner, repo, ref) 271 | if err != nil { 272 | return "", fmt.Errorf("failed to get checksum for tag: %w", err) 273 | } else if res != "" { 274 | return res, nil 275 | } 276 | 277 | // check branch 278 | if excludeBranch(cfg.Filter.ExcludeBranches, ref) { 279 | // if a branch is excluded, we won't know if it's a valid reference 280 | // but that's OK - we just won't touch that reference 281 | return "", fmt.Errorf("%w: %s", interfaces.ErrReferenceSkipped, ref) 282 | } 283 | 284 | res, err = getCheckSumForBranch(ctx, restIf, owner, repo, ref) 285 | if err != nil { 286 | return "", fmt.Errorf("failed to get checksum for branch: %w", err) 287 | } else if res != "" { 288 | return res, nil 289 | } 290 | 291 | return "", ErrInvalidActionReference 292 | } 293 | 294 | func parseActionFragments(action string) (owner string, repo string, err error) { 295 | frags := strings.Split(action, "/") 296 | 297 | // if we have more than 2 fragments, we're probably dealing with 298 | // sub-actions, so we take the first two fragments as the owner and repo 299 | if len(frags) < 2 { 300 | return "", "", fmt.Errorf("%w: '%s' reference is incorrect", ErrInvalidAction, action) 301 | } 302 | 303 | return frags[0], frags[1], nil 304 | } 305 | 306 | // isChecksum returns true if the input is a checksum. 307 | func isChecksum(ref string) bool { 308 | return len(ref) == 40 309 | } 310 | 311 | func getCheckSumForTag(ctx context.Context, restIf interfaces.REST, owner, repo, tag string) (string, error) { 312 | path, err := url.JoinPath("repos", owner, repo, "git", "refs", "tags", tag) 313 | if err != nil { 314 | return "", fmt.Errorf("failed to join path: %w", err) 315 | } 316 | 317 | sha, otype, err := doGetReference(ctx, restIf, path) 318 | if err != nil { 319 | return "", err 320 | } 321 | 322 | if otype == "commit" { 323 | return sha, nil 324 | } 325 | 326 | // assume otype == "tag" 327 | path, err = url.JoinPath("repos", owner, repo, "git", "tags", sha) 328 | if err != nil { 329 | return "", fmt.Errorf("failed to join path: %w", err) 330 | } 331 | 332 | sha, _, err = doGetReference(ctx, restIf, path) 333 | return sha, err 334 | } 335 | 336 | func getCheckSumForBranch(ctx context.Context, restIf interfaces.REST, owner, repo, branch string) (string, error) { 337 | path, err := url.JoinPath("repos", owner, repo, "git", "refs", "heads", branch) 338 | if err != nil { 339 | return "", fmt.Errorf("failed to join path: %w", err) 340 | } 341 | 342 | sha, _, err := doGetReference(ctx, restIf, path) 343 | return sha, err 344 | } 345 | 346 | func excludeBranch(excludes []string, branch string) bool { 347 | if len(excludes) == 0 { 348 | return false 349 | } 350 | if slices.Contains(excludes, "*") { 351 | return true 352 | } 353 | 354 | return slices.Contains(excludes, branch) 355 | } 356 | 357 | func doGetReference(ctx context.Context, restIf interfaces.REST, path string) (string, string, error) { 358 | req, err := restIf.NewRequest(http.MethodGet, path, nil) 359 | if err != nil { 360 | return "", "", fmt.Errorf("cannot create REST request: %w", err) 361 | } 362 | 363 | resp, err := restIf.Do(ctx, req) 364 | 365 | if resp != nil { 366 | defer func() { 367 | _ = resp.Body.Close() 368 | }() 369 | } 370 | 371 | if err != nil && resp.StatusCode != http.StatusNotFound { 372 | return "", "", fmt.Errorf("failed to do API request: %w", err) 373 | } else if resp.StatusCode == http.StatusNotFound { 374 | // No error, but no tag found 375 | return "", "", nil 376 | } 377 | 378 | var t github.Reference 379 | err = json.NewDecoder(resp.Body).Decode(&t) 380 | if err != nil && strings.Contains(err.Error(), "cannot unmarshal array into Go value of type") { 381 | // This is a branch, not a tag 382 | return "", "", nil 383 | } else if err != nil { 384 | return "", "", fmt.Errorf("canont decode response: %w", err) 385 | } 386 | 387 | return t.GetObject().GetSHA(), t.GetObject().GetType(), nil 388 | } 389 | -------------------------------------------------------------------------------- /pkg/replacer/actions/actions_test.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/stacklok/frizbee/pkg/utils/config" 11 | "github.com/stacklok/frizbee/pkg/utils/ghrest" 12 | "github.com/stacklok/frizbee/pkg/utils/store" 13 | ) 14 | 15 | func TestNewParser(t *testing.T) { 16 | t.Parallel() 17 | 18 | parser := New() 19 | require.NotNil(t, parser, "Parser should not be nil") 20 | require.Equal(t, GitHubActionsRegex, parser.regex, "Default regex should be GitHubActionsRegex") 21 | require.NotNil(t, parser.cache, "Cache should be initialized") 22 | } 23 | 24 | func TestSetCache(t *testing.T) { 25 | t.Parallel() 26 | 27 | parser := New() 28 | cache := store.NewRefCacher() 29 | parser.SetCache(cache) 30 | require.Equal(t, cache, parser.cache, "Cache should be set correctly") 31 | } 32 | 33 | func TestSetAndGetRegex(t *testing.T) { 34 | t.Parallel() 35 | 36 | parser := New() 37 | tests := []struct { 38 | name string 39 | newRegex string 40 | }{ 41 | { 42 | name: "Set and get new regex", 43 | newRegex: `new-regex`, 44 | }, 45 | } 46 | 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | t.Parallel() 50 | 51 | parser.SetRegex(tt.newRegex) 52 | require.Equal(t, tt.newRegex, parser.GetRegex(), "Regex should be set and retrieved correctly") 53 | }) 54 | } 55 | } 56 | 57 | func TestReplaceLocalPath(t *testing.T) { 58 | t.Parallel() 59 | 60 | parser := New() 61 | ctx := context.Background() 62 | cfg := config.Config{} 63 | restIf := &ghrest.Client{} 64 | 65 | tests := []struct { 66 | name string 67 | matchedLine string 68 | }{ 69 | { 70 | name: "Replace local path", 71 | matchedLine: "./local/path", 72 | }, 73 | } 74 | 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | t.Parallel() 78 | 79 | _, err := parser.Replace(ctx, tt.matchedLine, restIf, cfg) 80 | require.Error(t, err, "Should return error for local path") 81 | require.Contains(t, err.Error(), "reference skipped", "Error should indicate reference skipped") 82 | }) 83 | } 84 | } 85 | 86 | func TestReplaceExcludedPath(t *testing.T) { 87 | t.Parallel() 88 | 89 | parser := New() 90 | ctx := context.Background() 91 | cfg := config.Config{GHActions: config.GHActions{Filter: config.Filter{Exclude: []string{"actions/checkout"}}}} 92 | restIf := &ghrest.Client{} 93 | 94 | tests := []struct { 95 | name string 96 | matchedLine string 97 | }{ 98 | { 99 | name: "Replace excluded path", 100 | matchedLine: "uses: actions/checkout@v2", 101 | }, 102 | } 103 | 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | t.Parallel() 107 | 108 | _, err := parser.Replace(ctx, tt.matchedLine, restIf, cfg) 109 | require.Error(t, err, "Should return error for excluded path") 110 | require.Contains(t, err.Error(), "reference skipped", "Error should indicate reference skipped") 111 | }) 112 | } 113 | } 114 | 115 | func TestConvertToEntityRef(t *testing.T) { 116 | t.Parallel() 117 | 118 | parser := New() 119 | 120 | tests := []struct { 121 | name string 122 | reference string 123 | wantErr bool 124 | }{ 125 | {"Valid action reference", "uses: actions/checkout@v2", false}, 126 | {"Valid docker reference", "docker://mydocker/image:tag", false}, 127 | {"Invalid reference format", "invalid-reference", true}, 128 | } 129 | 130 | for _, tt := range tests { 131 | t.Run(tt.name, func(t *testing.T) { 132 | t.Parallel() 133 | 134 | ref, err := parser.ConvertToEntityRef(tt.reference) 135 | if tt.wantErr { 136 | require.Error(t, err, "Expected error but got none") 137 | } else { 138 | require.NoError(t, err, "Expected no error but got %v", err) 139 | require.NotNil(t, ref, "EntityRef should not be nil") 140 | } 141 | }) 142 | } 143 | } 144 | 145 | func TestIsLocal(t *testing.T) { 146 | t.Parallel() 147 | 148 | tests := []struct { 149 | name string 150 | input string 151 | want bool 152 | }{ 153 | {"Local path with ./", "./local/path", true}, 154 | {"Local path with ../", "../local/path", true}, 155 | {"Non-local path", "non/local/path", false}, 156 | } 157 | 158 | for _, tt := range tests { 159 | t.Run(tt.name, func(t *testing.T) { 160 | t.Parallel() 161 | 162 | require.Equal(t, tt.want, isLocal(tt.input), "IsLocal should return correct value") 163 | }) 164 | } 165 | } 166 | 167 | func TestShouldExclude(t *testing.T) { 168 | t.Parallel() 169 | 170 | cfg := &config.GHActions{Filter: config.Filter{Exclude: []string{"actions/checkout", "actions/setup"}}} 171 | 172 | tests := []struct { 173 | name string 174 | input string 175 | want bool 176 | }{ 177 | {"Excluded path", "actions/checkout", true}, 178 | {"Non-excluded path", "actions/unknown", false}, 179 | } 180 | 181 | for _, tt := range tests { 182 | t.Run(tt.name, func(t *testing.T) { 183 | t.Parallel() 184 | 185 | require.Equal(t, tt.want, shouldExclude(cfg, tt.input), "ShouldExclude should return correct value") 186 | }) 187 | } 188 | } 189 | 190 | func TestParseActionReference(t *testing.T) { 191 | t.Parallel() 192 | 193 | tests := []struct { 194 | name string 195 | input string 196 | wantAction string 197 | wantRef string 198 | wantErr bool 199 | }{ 200 | {"Valid action reference", "actions/checkout@v2", "actions/checkout", "v2", false}, 201 | {"Invalid reference format", "invalid-reference", "", "", true}, 202 | } 203 | 204 | for _, tt := range tests { 205 | t.Run(tt.name, func(t *testing.T) { 206 | t.Parallel() 207 | 208 | action, ref, err := ParseActionReference(tt.input) 209 | if tt.wantErr { 210 | require.Error(t, err, "Expected error but got none") 211 | } else { 212 | require.NoError(t, err, "Expected no error but got %v", err) 213 | require.Equal(t, tt.wantAction, action, "Action should be parsed correctly") 214 | require.Equal(t, tt.wantRef, ref, "Reference should be parsed correctly") 215 | } 216 | }) 217 | } 218 | } 219 | 220 | func TestGetChecksum(t *testing.T) { 221 | t.Parallel() 222 | 223 | tok := os.Getenv("GITHUB_TOKEN") 224 | ctx := context.Background() 225 | ghcli := ghrest.NewClient(tok) 226 | 227 | tests := []struct { 228 | name string 229 | args struct{ action, ref string } 230 | want string 231 | wantErr bool 232 | }{ 233 | { 234 | name: "actions/checkout with v4.1.1", 235 | args: struct{ action, ref string }{action: "actions/checkout", ref: "v4.1.1"}, 236 | want: "b4ffde65f46336ab88eb53be808477a3936bae11", 237 | wantErr: false, 238 | }, 239 | { 240 | name: "actions/checkout with v3.6.0", 241 | args: struct{ action, ref string }{action: "actions/checkout", ref: "v3.6.0"}, 242 | want: "f43a0e5ff2bd294095638e18286ca9a3d1956744", 243 | wantErr: false, 244 | }, 245 | { 246 | name: "actions/checkout with checksum returns checksum", 247 | args: struct{ action, ref string }{action: "actions/checkout", ref: "1d96c772d19495a3b5c517cd2bc0cb401ea0529f"}, 248 | want: "1d96c772d19495a3b5c517cd2bc0cb401ea0529f", 249 | wantErr: false, 250 | }, 251 | { 252 | name: "aquasecurity/trivy-action with 0.14.0", 253 | args: struct{ action, ref string }{action: "aquasecurity/trivy-action", ref: "0.14.0"}, 254 | want: "2b6a709cf9c4025c5438138008beaddbb02086f0", 255 | wantErr: false, 256 | }, 257 | { 258 | name: "aquasecurity/trivy-action with branch returns checksum", 259 | args: struct{ action, ref string }{action: "aquasecurity/trivy-action", ref: "bump-trivy"}, 260 | want: "fb5e1b36be448e92ca98648c661bd7e9da1f1317", 261 | wantErr: false, 262 | }, 263 | { 264 | name: "actions/checkout with invalid tag returns error", 265 | args: struct{ action, ref string }{action: "actions/checkout", ref: "v4.1.1.1"}, 266 | want: "", 267 | wantErr: true, 268 | }, 269 | { 270 | name: "actions/checkout with invalid action returns error", 271 | args: struct{ action, ref string }{action: "invalid-action", ref: "v4.1.1"}, 272 | want: "", 273 | wantErr: true, 274 | }, 275 | { 276 | name: "actions/checkout with empty action returns error", 277 | args: struct{ action, ref string }{action: "", ref: "v4.1.1"}, 278 | want: "", 279 | wantErr: true, 280 | }, 281 | { 282 | name: "actions/checkout with empty tag returns error", 283 | args: struct{ action, ref string }{action: "actions/checkout", ref: ""}, 284 | want: "", 285 | wantErr: true, 286 | }, 287 | { 288 | name: "actions/setup-node with v1 is an array", 289 | args: struct{ action, ref string }{action: "actions/setup-node", ref: "v1"}, 290 | want: "f1f314fca9dfce2769ece7d933488f076716723e", 291 | wantErr: false, 292 | }, 293 | { 294 | name: "anchore/sbom-action/download-syft with a sub-action works", 295 | args: struct{ action, ref string }{action: "anchore/sbom-action/download-syft", ref: "v0.14.3"}, 296 | want: "78fc58e266e87a38d4194b2137a3d4e9bcaf7ca1", 297 | wantErr: false, 298 | }, 299 | } 300 | 301 | for _, tt := range tests { 302 | t.Run(tt.name, func(t *testing.T) { 303 | t.Parallel() 304 | 305 | got, err := GetChecksum(ctx, config.GHActions{}, ghcli, tt.args.action, tt.args.ref) 306 | if tt.wantErr { 307 | require.Error(t, err, "Wanted error, got none") 308 | require.Empty(t, got, "Wanted empty string, got %v", got) 309 | return 310 | } 311 | require.NoError(t, err, "Wanted no error, got %v", err) 312 | require.Equal(t, tt.want, got, "Wanted %v, got %v", tt.want, got) 313 | }) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /pkg/replacer/image/image.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package image provides utilities to work with container images. 17 | package image 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "slices" 24 | "strings" 25 | 26 | "github.com/google/go-containerregistry/pkg/authn" 27 | "github.com/google/go-containerregistry/pkg/name" 28 | v1 "github.com/google/go-containerregistry/pkg/v1" 29 | "github.com/google/go-containerregistry/pkg/v1/remote" 30 | dockerparser "github.com/moby/buildkit/frontend/dockerfile/parser" 31 | 32 | "github.com/stacklok/frizbee/internal/cli" 33 | "github.com/stacklok/frizbee/pkg/interfaces" 34 | "github.com/stacklok/frizbee/pkg/utils/config" 35 | "github.com/stacklok/frizbee/pkg/utils/store" 36 | ) 37 | 38 | const ( 39 | // ContainerImageRegex is regular expression pattern to match container image usage in YAML 40 | // nolint:lll 41 | ContainerImageRegex = `image\s*:\s*["']?([^\s"']+/[^\s"']+|[^\s"']+)(:[^\s"']+)?(@[^\s"']+)?["']?|FROM\s+(--platform=[^\s]+[^\s]*\s+)?([^\s]+(/[^\s]+)?(:[^\s]+)?(@[^\s]+)?)` 42 | prefixFROM = "FROM " 43 | prefixImage = "image: " 44 | // ReferenceType is the type of the reference 45 | ReferenceType = "container" 46 | ) 47 | 48 | // Parser is a struct to replace container image references with digests 49 | type Parser struct { 50 | regex string 51 | cache store.RefCacher 52 | } 53 | 54 | type unresolvedImage struct { 55 | imageRef string 56 | flags []string 57 | } 58 | 59 | // New creates a new Parser 60 | func New() *Parser { 61 | return &Parser{ 62 | regex: ContainerImageRegex, 63 | cache: store.NewRefCacher(), 64 | } 65 | } 66 | 67 | // SetCache sets the cache to store the image references 68 | func (p *Parser) SetCache(cache store.RefCacher) { 69 | p.cache = cache 70 | } 71 | 72 | // SetRegex sets the regular expression pattern to match container image usage 73 | func (p *Parser) SetRegex(regex string) { 74 | p.regex = regex 75 | } 76 | 77 | // GetRegex returns the regular expression pattern to match container image usage 78 | func (p *Parser) GetRegex() string { 79 | return p.regex 80 | } 81 | 82 | // Replace replaces the container image reference with the digest 83 | func (p *Parser) Replace( 84 | ctx context.Context, 85 | matchedLine string, 86 | _ interfaces.REST, 87 | cfg config.Config, 88 | ) (*interfaces.EntityRef, error) { 89 | var imageRef string 90 | var extraArgs string 91 | 92 | // Trim the prefix 93 | hasFROMPrefix := false 94 | hasImagePrefix := false 95 | // Check if the image reference has the FROM prefix, i.e. Dockerfile 96 | if strings.HasPrefix(matchedLine, prefixFROM) { 97 | parsedFrom, err := getRefFromDockerfileFROM(matchedLine) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | // Check if the image reference should be excluded, i.e. scratch 103 | if shouldSkipImageRef(&cfg, parsedFrom.imageRef) { 104 | return nil, fmt.Errorf("image reference %s should be excluded - %w", matchedLine, interfaces.ErrReferenceSkipped) 105 | } 106 | 107 | imageRef = parsedFrom.imageRef 108 | extraArgs = strings.Join(parsedFrom.flags, " ") 109 | if extraArgs != "" { 110 | extraArgs += " " 111 | } 112 | 113 | hasFROMPrefix = true 114 | } else if strings.HasPrefix(matchedLine, prefixImage) { 115 | // Check if the image reference has the image prefix, i.e. Kubernetes or Docker Compose YAML 116 | imageRef = strings.TrimPrefix(matchedLine, prefixImage) 117 | // Check if the image reference should be excluded, i.e. scratch 118 | if shouldSkipImageRef(&cfg, imageRef) { 119 | return nil, fmt.Errorf("image reference %s should be excluded - %w", matchedLine, interfaces.ErrReferenceSkipped) 120 | } 121 | hasImagePrefix = true 122 | } else { 123 | imageRef = matchedLine 124 | } 125 | 126 | // Get the digest of the image reference 127 | imageRefWithDigest, err := GetImageDigestFromRef(ctx, imageRef, cfg.Platform, p.cache) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | // Add the prefix back 133 | if hasFROMPrefix { 134 | imageRefWithDigest.Prefix = fmt.Sprintf("%s%s%s", prefixFROM, extraArgs, imageRefWithDigest.Prefix) 135 | } else if hasImagePrefix { 136 | imageRefWithDigest.Prefix = fmt.Sprintf("%s%s", prefixImage, imageRefWithDigest.Prefix) 137 | } 138 | 139 | // Return the reference 140 | return imageRefWithDigest, nil 141 | } 142 | 143 | // ConvertToEntityRef converts a container image reference to an EntityRef 144 | func (_ *Parser) ConvertToEntityRef(reference string) (*interfaces.EntityRef, error) { 145 | reference = strings.TrimPrefix(reference, prefixImage) 146 | reference = strings.TrimPrefix(reference, prefixFROM) 147 | var sep string 148 | var frags []string 149 | if strings.Contains(reference, "@") { 150 | sep = "@" 151 | } else if strings.Contains(reference, ":") { 152 | sep = ":" 153 | } 154 | 155 | if sep != "" { 156 | frags = strings.Split(reference, sep) 157 | if len(frags) != 2 { 158 | return nil, fmt.Errorf("invalid container reference: %s", reference) 159 | } 160 | } else { 161 | frags = []string{reference, "latest"} 162 | } 163 | 164 | return &interfaces.EntityRef{ 165 | Name: frags[0], 166 | Ref: frags[1], 167 | Type: ReferenceType, 168 | }, nil 169 | } 170 | 171 | // GetImageDigestFromRef returns the digest of a container image reference 172 | // from a name.Reference. 173 | func GetImageDigestFromRef(ctx context.Context, imageRef, platform string, cache store.RefCacher) (*interfaces.EntityRef, error) { 174 | // Parse the image reference 175 | ref, err := name.ParseReference(imageRef) 176 | if err != nil { 177 | return nil, err 178 | } 179 | opts := []remote.Option{ 180 | remote.WithContext(ctx), 181 | remote.WithUserAgent(cli.UserAgent), 182 | remote.WithAuthFromKeychain(authn.DefaultKeychain), 183 | } 184 | 185 | // Set the platform if provided 186 | if platform != "" { 187 | platformSplit := strings.Split(platform, "/") 188 | if len(platformSplit) != 2 { 189 | return nil, errors.New("platform must be in the format os/arch") 190 | } 191 | opts = append(opts, remote.WithPlatform(v1.Platform{ 192 | OS: platformSplit[0], 193 | Architecture: platformSplit[1], 194 | })) 195 | } 196 | 197 | // Get the digest of the image reference 198 | var digest string 199 | 200 | if cache != nil { 201 | if d, ok := cache.Load(imageRef); ok { 202 | digest = d 203 | } else { 204 | desc, err := remote.Get(ref, opts...) 205 | if err != nil { 206 | return nil, err 207 | } 208 | digest = desc.Digest.String() 209 | cache.Store(imageRef, digest) 210 | } 211 | } else { 212 | desc, err := remote.Get(ref, opts...) 213 | if err != nil { 214 | return nil, err 215 | } 216 | digest = desc.Digest.String() 217 | } 218 | 219 | // Compare the digest with the reference and return the original reference if they already match 220 | if digest == ref.Identifier() { 221 | return nil, fmt.Errorf("image already referenced by digest: %s %w", imageRef, interfaces.ErrReferenceSkipped) 222 | } 223 | 224 | return &interfaces.EntityRef{ 225 | Name: ref.Context().Name(), 226 | Ref: digest, 227 | Type: ReferenceType, 228 | Tag: ref.Identifier(), 229 | }, nil 230 | } 231 | 232 | func shouldSkipImageRef(cfg *config.Config, ref string) bool { 233 | // Parse the image reference 234 | nameRef, err := name.ParseReference(ref) 235 | if err != nil { 236 | // we wouldn't know how to resolve this reference, so let's skip 237 | return true 238 | } 239 | 240 | imageName := getImageNameFromRef(nameRef) 241 | if slices.Contains(cfg.Images.ImageFilter.ExcludeImages, imageName) { 242 | return true 243 | } 244 | 245 | tag := nameRef.Identifier() 246 | return slices.Contains(cfg.Images.ImageFilter.ExcludeTags, tag) 247 | } 248 | 249 | // TODO(jakub): this is a bit of a hack, but I didn't find a better way to get just the name 250 | func getImageNameFromRef(nameRef name.Reference) string { 251 | fullRepositoryName := nameRef.Context().Name() 252 | parts := strings.Split(fullRepositoryName, "/") 253 | if len(parts) > 1 { 254 | return parts[len(parts)-1] 255 | } 256 | 257 | return "" 258 | } 259 | func getRefFromDockerfileFROM(line string) (unresolvedImage, error) { 260 | parseResult, err := dockerparser.Parse(strings.NewReader(line)) 261 | if err != nil { 262 | return unresolvedImage{}, fmt.Errorf("failed to parse Dockerfile line: %w", err) 263 | } 264 | 265 | if len(parseResult.AST.Children) == 0 || 266 | parseResult.AST.Children[0] == nil || 267 | strings.ToUpper(parseResult.AST.Children[0].Value) != "FROM" { 268 | return unresolvedImage{}, errors.New("invalid Dockerfile line: the first parsed node is not FROM") 269 | } 270 | 271 | fromNode := parseResult.AST.Children[0] 272 | 273 | imgNode := parseResult.AST.Children[0].Next 274 | if imgNode == nil { 275 | return unresolvedImage{}, errors.New("invalid Dockerfile line: no image node found") 276 | } 277 | 278 | return unresolvedImage{ 279 | imageRef: imgNode.Value, 280 | flags: fromNode.Flags, 281 | }, nil 282 | } 283 | -------------------------------------------------------------------------------- /pkg/replacer/image/image_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/stacklok/frizbee/pkg/interfaces" 10 | "github.com/stacklok/frizbee/pkg/utils/config" 11 | "github.com/stacklok/frizbee/pkg/utils/store" 12 | ) 13 | 14 | func TestNewParser(t *testing.T) { 15 | t.Parallel() 16 | 17 | tests := []struct { 18 | name string 19 | }{ 20 | {"New parser initialization"}, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | t.Parallel() 26 | parser := New() 27 | require.NotNil(t, parser, "Parser should not be nil") 28 | require.Equal(t, ContainerImageRegex, parser.regex, "Default regex should be ContainerImageRegex") 29 | require.NotNil(t, parser.cache, "Cache should be initialized") 30 | }) 31 | } 32 | } 33 | 34 | func TestSetCache(t *testing.T) { 35 | t.Parallel() 36 | 37 | tests := []struct { 38 | name string 39 | cache store.RefCacher 40 | }{ 41 | {"Set cache for parser", store.NewRefCacher()}, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | t.Parallel() 47 | parser := New() 48 | parser.SetCache(tt.cache) 49 | require.Equal(t, tt.cache, parser.cache, "Cache should be set correctly") 50 | }) 51 | } 52 | } 53 | 54 | func TestSetAndGetRegex(t *testing.T) { 55 | t.Parallel() 56 | 57 | tests := []struct { 58 | name string 59 | newRegex string 60 | }{ 61 | {"Set and get new regex", `new-regex`}, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | t.Parallel() 67 | parser := New() 68 | parser.SetRegex(tt.newRegex) 69 | require.Equal(t, tt.newRegex, parser.GetRegex(), "Regex should be set and retrieved correctly") 70 | }) 71 | } 72 | } 73 | 74 | func TestReplaceExcludedPath(t *testing.T) { 75 | t.Parallel() 76 | 77 | parser := New() 78 | ctx := context.Background() 79 | cfg := config.Config{ 80 | Images: config.Images{ 81 | ImageFilter: config.ImageFilter{ 82 | ExcludeImages: []string{"scratch"}, 83 | ExcludeTags: []string{"latest"}, 84 | }, 85 | }, 86 | } 87 | 88 | tests := []struct { 89 | name string 90 | matchedLine string 91 | expected error 92 | }{ 93 | { 94 | "Do not replace scratch FROM image", 95 | "FROM scratch", 96 | interfaces.ErrReferenceSkipped, 97 | }, 98 | { 99 | "Do not replace ubuntu:latest", 100 | "FROM ubuntu:latest", 101 | interfaces.ErrReferenceSkipped, 102 | }, 103 | { 104 | "Do not replace ubuntu:latest with AS", 105 | "FROM ubuntu:latest AS builder", 106 | interfaces.ErrReferenceSkipped, 107 | }, 108 | { 109 | "Do not replace ubuntu without a tag", 110 | "FROM ubuntu", 111 | interfaces.ErrReferenceSkipped, 112 | }, 113 | { 114 | "Do not replace ubuntu without a tag with a stage", 115 | "FROM ubuntu AS builder", 116 | interfaces.ErrReferenceSkipped, 117 | }, 118 | { 119 | "Replace ubuntu:22.04", 120 | "FROM ubuntu:22.04", 121 | nil, 122 | }, 123 | { 124 | "Replace ubuntu:22.04 with AS", 125 | "FROM ubuntu:22.04 AS builder", 126 | nil, 127 | }, 128 | { 129 | "Replace ubuntu:22.04 with AS", 130 | "FROM --platform=linux/amd64 ubuntu:22.04 AS builder", 131 | nil, 132 | }, 133 | { 134 | "Replace with repo reference and tag", 135 | "FROM ghcr.io/stacklok/minder/helm/minder:0.20231123.829_ref.26ca90b", 136 | nil, 137 | }, 138 | } 139 | 140 | for _, tt := range tests { 141 | t.Run(tt.name, func(t *testing.T) { 142 | t.Parallel() 143 | _, err := parser.Replace(ctx, tt.matchedLine, nil, cfg) 144 | if tt.expected == nil { 145 | require.NoError(t, err, "Should not return error for excluded path") 146 | } else { 147 | require.Error(t, err, "Should return error for excluded path") 148 | require.ErrorIs(t, err, tt.expected, "Unexpected error") 149 | } 150 | }) 151 | } 152 | } 153 | 154 | func TestConvertToEntityRef(t *testing.T) { 155 | t.Parallel() 156 | 157 | parser := New() 158 | 159 | tests := []struct { 160 | name string 161 | reference string 162 | wantErr bool 163 | }{ 164 | {"Valid container reference with tag", "ghcr.io/stacklok/minder/helm/minder:0.20231123.829_ref.26ca90b", false}, 165 | {"Valid container reference with digest", "ghcr.io/stacklok/minder/helm/minder@sha256:a29f8a8d28f0af7f70a4b3dd3e33c8c8cc5cf9e88e802e2700cf272a0b6140ec", false}, 166 | {"Invalid reference format", "invalid:reference:format", true}, 167 | } 168 | 169 | for _, tt := range tests { 170 | t.Run(tt.name, func(t *testing.T) { 171 | t.Parallel() 172 | ref, err := parser.ConvertToEntityRef(tt.reference) 173 | if tt.wantErr { 174 | require.Error(t, err, "Expected error but got none") 175 | } else { 176 | require.NoError(t, err, "Expected no error but got %v", err) 177 | require.NotNil(t, ref, "EntityRef should not be nil") 178 | } 179 | }) 180 | } 181 | } 182 | 183 | func TestGetImageDigestFromRef(t *testing.T) { 184 | t.Parallel() 185 | 186 | ctx := context.Background() 187 | 188 | tests := []struct { 189 | name string 190 | refstr string 191 | want string 192 | wantErr bool 193 | }{ 194 | { 195 | name: "Valid image reference 1", 196 | refstr: "ghcr.io/stacklok/minder/helm/minder:0.20231123.829_ref.26ca90b", 197 | want: "sha256:a29f8a8d28f0af7f70a4b3dd3e33c8c8cc5cf9e88e802e2700cf272a0b6140ec", 198 | }, 199 | { 200 | name: "Valid image reference 2", 201 | refstr: "devopsfaith/krakend:2.5.0", 202 | want: "sha256:6a3c8e5e1a4948042bfb364ed6471e16b4a26d0afb6c3c01ebcb88b3fa551036", 203 | }, 204 | { 205 | name: "Invalid ref string", 206 | refstr: "ghcr.io/stacklok/minder/helm/minder!", 207 | wantErr: true, 208 | }, 209 | { 210 | name: "Nonexistent container in nonexistent registry", 211 | refstr: "beeeeer.io/ipa/toppling-goliath/king-sue:1.0.0", 212 | wantErr: true, 213 | }, 214 | } 215 | 216 | for _, tt := range tests { 217 | t.Run(tt.name, func(t *testing.T) { 218 | t.Parallel() 219 | got, err := GetImageDigestFromRef(ctx, tt.refstr, "", nil) 220 | if tt.wantErr { 221 | require.Error(t, err) 222 | require.Nil(t, got) 223 | return 224 | } 225 | 226 | require.NoError(t, err) 227 | require.Equal(t, tt.want, got.Ref) 228 | }) 229 | } 230 | } 231 | 232 | func TestShouldSkipImage(t *testing.T) { 233 | t.Parallel() 234 | 235 | tests := []struct { 236 | name string 237 | ref string 238 | skip bool 239 | }{ 240 | // skip cases 241 | {"Skip scratch", "scratch", true}, 242 | {"Skip ubuntu without a tag", "ubuntu", true}, 243 | {"Skip ubuntu:latest", "ubuntu:latest", true}, 244 | // keep cases 245 | {"Do not skip ubuntu:22.04", "ubuntu:22.04", false}, 246 | {"Do not skip with repo reference and tag", "myrepo/myimage:1.2.3", false}, 247 | } 248 | 249 | for _, tt := range tests { 250 | t.Run(tt.name, func(t *testing.T) { 251 | t.Parallel() 252 | 253 | config := &config.Config{ 254 | Images: config.Images{ 255 | ImageFilter: config.ImageFilter{ 256 | ExcludeImages: []string{"scratch"}, 257 | ExcludeTags: []string{"latest"}, 258 | }, 259 | }, 260 | } 261 | 262 | got := shouldSkipImageRef(config, tt.ref) 263 | require.Equal(t, tt.skip, got, "shouldSkipImageRef should return the correct exclusion status") 264 | }) 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /pkg/replacer/replacer.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package replacer provide common replacer implementation 17 | package replacer 18 | 19 | import ( 20 | "bufio" 21 | "context" 22 | "fmt" 23 | "io" 24 | "path/filepath" 25 | "regexp" 26 | "sort" 27 | "strings" 28 | "sync" 29 | 30 | mapset "github.com/deckarep/golang-set/v2" 31 | "github.com/go-git/go-billy/v5" 32 | "github.com/go-git/go-billy/v5/osfs" 33 | "golang.org/x/sync/errgroup" 34 | 35 | "github.com/stacklok/frizbee/internal/traverse" 36 | "github.com/stacklok/frizbee/pkg/interfaces" 37 | "github.com/stacklok/frizbee/pkg/replacer/actions" 38 | "github.com/stacklok/frizbee/pkg/replacer/image" 39 | "github.com/stacklok/frizbee/pkg/utils/config" 40 | "github.com/stacklok/frizbee/pkg/utils/ghrest" 41 | ) 42 | 43 | // ReplaceResult holds a slice of all processed files along with a map of their modified content 44 | type ReplaceResult struct { 45 | Processed []string 46 | Modified map[string]string 47 | } 48 | 49 | // ListResult holds the result of the list methods 50 | type ListResult struct { 51 | Processed []string 52 | Entities []interfaces.EntityRef 53 | } 54 | 55 | // Replacer is an object with methods to replace references with digests 56 | type Replacer struct { 57 | parser interfaces.Parser 58 | rest interfaces.REST 59 | cfg config.Config 60 | } 61 | 62 | // NewGitHubActionsReplacer creates a new replacer for GitHub actions 63 | func NewGitHubActionsReplacer(cfg *config.Config) *Replacer { 64 | cfg = config.MergeUserConfig(cfg) 65 | 66 | return &Replacer{ 67 | cfg: *cfg, 68 | parser: actions.New(), 69 | rest: ghrest.NewClient(""), 70 | } 71 | } 72 | 73 | // NewContainerImagesReplacer creates a new replacer for container images 74 | func NewContainerImagesReplacer(cfg *config.Config) *Replacer { 75 | cfg = config.MergeUserConfig(cfg) 76 | 77 | return &Replacer{ 78 | cfg: *cfg, 79 | parser: image.New(), 80 | rest: ghrest.NewClient(""), 81 | } 82 | } 83 | 84 | // WithGitHubClientFromToken creates an authenticated GitHub client from a token 85 | func (r *Replacer) WithGitHubClientFromToken(token string) *Replacer { 86 | client := ghrest.NewClient(token) 87 | r.rest = client 88 | return r 89 | } 90 | 91 | // WithGitHubClient sets the GitHub client to use 92 | func (r *Replacer) WithGitHubClient(client interfaces.REST) *Replacer { 93 | r.rest = client 94 | return r 95 | } 96 | 97 | // WithUserRegex sets a user-provided regex for the parser 98 | func (r *Replacer) WithUserRegex(regex string) *Replacer { 99 | if r.parser != nil && regex != "" { 100 | r.parser.SetRegex(regex) 101 | } 102 | return r 103 | } 104 | 105 | // WithCacheDisabled disables caching 106 | func (r *Replacer) WithCacheDisabled() *Replacer { 107 | r.parser.SetCache(nil) 108 | return r 109 | } 110 | 111 | // ParseString parses and returns the referenced entity pinned by its digest 112 | func (r *Replacer) ParseString(ctx context.Context, entityRef string) (*interfaces.EntityRef, error) { 113 | return r.parser.Replace(ctx, entityRef, r.rest, r.cfg) 114 | } 115 | 116 | // ParsePath parses and replaces all entity references in the provided directory 117 | func (r *Replacer) ParsePath(ctx context.Context, dir string) (*ReplaceResult, error) { 118 | return parsePathInFS(ctx, r.parser, r.rest, r.cfg, osfs.New(filepath.Dir(dir), osfs.WithBoundOS()), filepath.Base(dir)) 119 | } 120 | 121 | // ParsePathInFS parses and replaces all entity references in the provided file system 122 | func (r *Replacer) ParsePathInFS(ctx context.Context, bfs billy.Filesystem, base string) (*ReplaceResult, error) { 123 | return parsePathInFS(ctx, r.parser, r.rest, r.cfg, bfs, base) 124 | } 125 | 126 | // ParseFile parses and replaces all entity references in the provided file 127 | func (r *Replacer) ParseFile(ctx context.Context, f io.Reader) (bool, string, error) { 128 | return parseAndReplaceReferencesInFile(ctx, f, r.parser, r.rest, r.cfg) 129 | } 130 | 131 | // ListPath lists all entity references in the provided directory 132 | func (r *Replacer) ListPath(dir string) (*ListResult, error) { 133 | return listReferencesInFS(r.parser, osfs.New(filepath.Dir(dir), osfs.WithBoundOS()), filepath.Base(dir)) 134 | } 135 | 136 | // ListPathInFS lists all entity references in the provided file system 137 | func (r *Replacer) ListPathInFS(bfs billy.Filesystem, base string) (*ListResult, error) { 138 | return listReferencesInFS(r.parser, bfs, base) 139 | } 140 | 141 | // ListInFile lists all entities in the provided file 142 | func (r *Replacer) ListInFile(f io.Reader) (*ListResult, error) { 143 | found, err := listReferencesInFile(f, r.parser) 144 | if err != nil { 145 | return nil, err 146 | } 147 | res := &ListResult{} 148 | res.Entities = found.ToSlice() 149 | 150 | // Sort the slice 151 | sort.Slice(res.Entities, func(i, j int) bool { 152 | return res.Entities[i].Name < res.Entities[j].Name 153 | }) 154 | 155 | // All good 156 | return res, nil 157 | } 158 | 159 | func parsePathInFS( 160 | ctx context.Context, 161 | parser interfaces.Parser, 162 | rest interfaces.REST, 163 | cfg config.Config, 164 | bfs billy.Filesystem, 165 | base string, 166 | ) (*ReplaceResult, error) { 167 | var eg errgroup.Group 168 | var mu sync.Mutex 169 | 170 | res := ReplaceResult{ 171 | Processed: make([]string, 0), 172 | Modified: make(map[string]string), 173 | } 174 | 175 | // Traverse all YAML/YML files in dir 176 | err := traverse.YamlDockerfiles(bfs, base, func(path string) error { 177 | eg.Go(func() error { 178 | file, err := bfs.Open(path) 179 | if err != nil { 180 | return fmt.Errorf("failed to open file %s: %w", path, err) 181 | } 182 | // nolint:errcheck // ignore error 183 | defer file.Close() 184 | 185 | // Parse the content of the file and update the matching references 186 | modified, updatedFile, err := parseAndReplaceReferencesInFile(ctx, file, parser, rest, cfg) 187 | if err != nil { 188 | return fmt.Errorf("failed to modify references in %s: %w", path, err) 189 | } 190 | 191 | mu.Lock() 192 | // Store the file name to the processed batch 193 | res.Processed = append(res.Processed, path) 194 | // Store the updated file content if it was modified 195 | if modified { 196 | res.Modified[path] = updatedFile 197 | } 198 | mu.Unlock() 199 | 200 | // All good 201 | return nil 202 | }) 203 | return nil 204 | }) 205 | if err != nil { 206 | return nil, err 207 | } 208 | 209 | if err := eg.Wait(); err != nil { 210 | return nil, err 211 | } 212 | 213 | // All good 214 | return &res, nil 215 | } 216 | 217 | func listReferencesInFS(parser interfaces.Parser, bfs billy.Filesystem, base string) (*ListResult, error) { 218 | var eg errgroup.Group 219 | var mu sync.Mutex 220 | 221 | res := ListResult{ 222 | Processed: make([]string, 0), 223 | Entities: make([]interfaces.EntityRef, 0), 224 | } 225 | 226 | found := mapset.NewSet[interfaces.EntityRef]() 227 | 228 | // Traverse all related files 229 | err := traverse.YamlDockerfiles(bfs, base, func(path string) error { 230 | eg.Go(func() error { 231 | file, err := bfs.Open(path) 232 | if err != nil { 233 | return fmt.Errorf("failed to open file %s: %w", path, err) 234 | } 235 | defer file.Close() // nolint:errcheck 236 | 237 | // Parse the content of the file and list the matching references 238 | foundRefs, err := listReferencesInFile(file, parser) 239 | if err != nil { 240 | return fmt.Errorf("failed to list references in %s: %w", path, err) 241 | } 242 | 243 | // Store the file name to the processed batch 244 | mu.Lock() 245 | res.Processed = append(res.Processed, path) 246 | found = found.Union(foundRefs) 247 | mu.Unlock() 248 | 249 | // All good 250 | return nil 251 | }) 252 | return nil 253 | }) 254 | if err != nil { 255 | return nil, err 256 | } 257 | 258 | if err := eg.Wait(); err != nil { 259 | return nil, err 260 | } 261 | res.Entities = found.ToSlice() 262 | 263 | // Sort the slice 264 | sort.Slice(res.Entities, func(i, j int) bool { 265 | return res.Entities[i].Name < res.Entities[j].Name 266 | }) 267 | 268 | // All good 269 | return &res, nil 270 | } 271 | 272 | func parseAndReplaceReferencesInFile( 273 | ctx context.Context, 274 | f io.Reader, 275 | parser interfaces.Parser, 276 | rest interfaces.REST, 277 | cfg config.Config, 278 | ) (bool, string, error) { 279 | var contentBuilder strings.Builder 280 | var ret *interfaces.EntityRef 281 | 282 | modified := false 283 | 284 | // Compile the regular expression 285 | re, err := regexp.Compile(parser.GetRegex()) 286 | if err != nil { 287 | return false, "", err 288 | } 289 | 290 | // Read the file line by line 291 | scanner := bufio.NewScanner(f) 292 | for scanner.Scan() { 293 | line := scanner.Text() 294 | 295 | // Skip commented lines 296 | if strings.HasPrefix(strings.TrimLeft(line, " \t\n\r"), "#") { 297 | // Write the line to the content builder buffer 298 | contentBuilder.WriteString(line + "\n") 299 | continue 300 | } 301 | 302 | // See if we can match an entity reference in the line 303 | newLine := re.ReplaceAllStringFunc(line, func(matchedLine string) string { 304 | // Modify the reference in the line 305 | ret, err = parser.Replace(ctx, matchedLine, rest, cfg) 306 | if err != nil { 307 | // Return the original line as we don't want to update it in case something errored out 308 | return matchedLine 309 | } 310 | // Construct the new line, comments in dockerfiles are handled differently than yml files 311 | if strings.Contains(matchedLine, "FROM") { 312 | return fmt.Sprintf("%s%s:%s@%s", ret.Prefix, ret.Name, ret.Tag, ret.Ref) 313 | } 314 | return fmt.Sprintf("%s%s@%s # %s", ret.Prefix, ret.Name, ret.Ref, ret.Tag) 315 | }) 316 | 317 | // Check if the line was modified and set the modified flag to true if it was 318 | if newLine != line { 319 | modified = true 320 | } 321 | 322 | // Write the line to the content builder buffer 323 | contentBuilder.WriteString(newLine + "\n") 324 | } 325 | 326 | // Check for errors during the scan 327 | if err := scanner.Err(); err != nil { 328 | return false, "", err 329 | } 330 | 331 | // Return the workflow content 332 | return modified, contentBuilder.String(), nil 333 | } 334 | 335 | // listReferencesInFile takes the given file reader and returns a map of all references, action or images it finds 336 | func listReferencesInFile( 337 | f io.Reader, 338 | parser interfaces.Parser, 339 | ) (mapset.Set[interfaces.EntityRef], error) { 340 | found := mapset.NewSet[interfaces.EntityRef]() 341 | 342 | // Compile the regular expression 343 | re, err := regexp.Compile(parser.GetRegex()) 344 | if err != nil { 345 | return nil, err 346 | } 347 | 348 | // Read the file line by line 349 | scanner := bufio.NewScanner(f) 350 | for scanner.Scan() { 351 | line := scanner.Text() 352 | 353 | // Skip commented lines 354 | if strings.HasPrefix(strings.TrimLeft(line, " \t\n\r"), "#") { 355 | continue 356 | } 357 | 358 | // See if we can match an entity reference in the line 359 | foundEntries := re.FindAllString(line, -1) 360 | // nolint:gosimple 361 | if foundEntries != nil { 362 | for _, entry := range foundEntries { 363 | e, err := parser.ConvertToEntityRef(entry) 364 | if err != nil { 365 | continue 366 | } 367 | found.Add(*e) 368 | } 369 | } 370 | } 371 | 372 | // Check for errors during the scan 373 | if err := scanner.Err(); err != nil { 374 | return nil, err 375 | } 376 | 377 | // Return the found references 378 | return found, nil 379 | } 380 | -------------------------------------------------------------------------------- /pkg/utils/config/config.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package config provides the frizbee configuration. 17 | package config 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "io" 23 | "os" 24 | "path/filepath" 25 | "slices" 26 | 27 | "github.com/go-git/go-billy/v5" 28 | "github.com/go-git/go-billy/v5/osfs" 29 | "github.com/spf13/cobra" 30 | "gopkg.in/yaml.v3" 31 | ) 32 | 33 | type contextConfigKey struct{} 34 | 35 | // ContextConfigKey is the context key for the configuration. 36 | // nolint:gochecknoglobals // this is a context key 37 | var ContextConfigKey = contextConfigKey{} 38 | 39 | var ( 40 | // ErrNoConfigInContext is returned when no configuration is found in the context. 41 | ErrNoConfigInContext = errors.New("no configuration found in context") 42 | ) 43 | 44 | // FromCommand returns the configuration from the cobra command. 45 | func FromCommand(cmd *cobra.Command) (*Config, error) { 46 | ctx := cmd.Context() 47 | cfg, ok := ctx.Value(ContextConfigKey).(*Config) 48 | if !ok { 49 | return nil, ErrNoConfigInContext 50 | } 51 | 52 | // If the platform flag is set, override the platform in the configuration. 53 | if cmd.Flags().Lookup("platform") != nil { 54 | cfg.Platform = cmd.Flag("platform").Value.String() 55 | } 56 | return cfg, nil 57 | } 58 | 59 | // Config is the frizbee configuration. 60 | type Config struct { 61 | Platform string `yaml:"platform" mapstructure:"platform"` 62 | GHActions GHActions `yaml:"ghactions" mapstructure:"ghactions"` 63 | Images Images `yaml:"images" mapstructure:"images"` 64 | } 65 | 66 | // GHActions is the GitHub Actions configuration. 67 | type GHActions struct { 68 | Filter `yaml:",inline" mapstructure:",inline"` 69 | } 70 | 71 | // Filter is a common configuration for filtering out patterns. 72 | type Filter struct { 73 | // Exclude is a list of patterns to exclude. 74 | Exclude []string `yaml:"exclude" mapstructure:"exclude"` 75 | ExcludeBranches []string `yaml:"exclude_branches" mapstructure:"exclude_branches"` 76 | } 77 | 78 | // Images is the image configuration. 79 | type Images struct { 80 | ImageFilter `yaml:",inline" mapstructure:",inline"` 81 | } 82 | 83 | // ImageFilter is the image filter configuration. 84 | type ImageFilter struct { 85 | // ExcludeImages is a regex that must match in order for an image to be excluded and not pinned 86 | ExcludeImages []string `yaml:"exclude_images" mapstructure:"exclude_images"` 87 | ExcludeTags []string `yaml:"exclude_tags" mapstructure:"exclude_tags"` 88 | } 89 | 90 | // ParseConfigFile parses a configuration file. 91 | func ParseConfigFile(configfile string) (*Config, error) { 92 | bfs := osfs.New(".") 93 | return ParseConfigFileFromFS(bfs, configfile) 94 | } 95 | 96 | // DefaultConfig returns the default configuration. 97 | func DefaultConfig() *Config { 98 | return &Config{ 99 | GHActions: GHActions{ 100 | Filter: Filter{ 101 | ExcludeBranches: []string{"main", "master"}, 102 | }, 103 | }, 104 | Images: Images{ 105 | ImageFilter: ImageFilter{ 106 | ExcludeImages: []string{"scratch"}, 107 | ExcludeTags: []string{"latest"}, 108 | }, 109 | }, 110 | } 111 | } 112 | 113 | // MergeUserConfig merges the user configuration with the default configuration. 114 | // mostly making sure that we don't try to pin the scratch image 115 | func MergeUserConfig(userConfig *Config) *Config { 116 | if userConfig == nil { 117 | return DefaultConfig() 118 | } 119 | 120 | if userConfig.Images.ExcludeImages == nil { 121 | userConfig.Images.ExcludeImages = []string{"scratch"} 122 | } 123 | 124 | if !slices.Contains(userConfig.Images.ExcludeImages, "scratch") { 125 | userConfig.Images.ExcludeImages = append(userConfig.Images.ExcludeImages, "scratch") 126 | } 127 | 128 | return userConfig 129 | } 130 | 131 | // ParseConfigFileFromFS parses a configuration file from a filesystem. 132 | func ParseConfigFileFromFS(fs billy.Filesystem, configfile string) (*Config, error) { 133 | cfg := DefaultConfig() 134 | cleancfgfile := filepath.Clean(configfile) 135 | cfgF, err := fs.Open(cleancfgfile) 136 | if err != nil { 137 | if os.IsNotExist(err) { 138 | return cfg, nil 139 | } 140 | 141 | return nil, fmt.Errorf("failed to open config file: %w", err) 142 | } 143 | defer cfgF.Close() // nolint:errcheck 144 | 145 | dec := yaml.NewDecoder(cfgF) 146 | if err := dec.Decode(cfg); err != nil { 147 | if err != io.EOF { 148 | return nil, fmt.Errorf("failed to decode config file: %w", err) 149 | } 150 | } 151 | 152 | return cfg, nil 153 | } 154 | -------------------------------------------------------------------------------- /pkg/utils/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/go-git/go-billy/v5/memfs" 8 | "github.com/spf13/cobra" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestFromCommand(t *testing.T) { 13 | t.Parallel() 14 | 15 | testCases := []struct { 16 | name string 17 | contextCfg *Config 18 | platformFlag string 19 | expectedCfg *Config 20 | expectError bool 21 | }{ 22 | { 23 | name: "NoConfigInContext", 24 | contextCfg: nil, 25 | expectError: true, 26 | }, 27 | { 28 | name: "WithConfigInContext", 29 | contextCfg: &Config{Platform: "linux/arm64"}, 30 | expectedCfg: &Config{Platform: "linux/arm64"}, 31 | }, 32 | { 33 | name: "WithPlatformFlag", 34 | contextCfg: &Config{Platform: "linux/amd64"}, 35 | platformFlag: "windows/arm64", 36 | expectedCfg: &Config{Platform: "windows/arm64"}, 37 | }, 38 | } 39 | 40 | for _, tt := range testCases { 41 | t.Run(tt.name, func(t *testing.T) { 42 | t.Parallel() 43 | 44 | ctx := context.Background() 45 | 46 | cmd := &cobra.Command{} 47 | if tt.contextCfg != nil { 48 | ctx := context.WithValue(ctx, ContextConfigKey, tt.contextCfg) 49 | cmd.SetContext(ctx) 50 | } else { 51 | cmd.SetContext(ctx) 52 | } 53 | if tt.platformFlag != "" { 54 | cmd.Flags().String("platform", "", "platform") 55 | require.NoError(t, cmd.Flags().Set("platform", tt.platformFlag)) 56 | } 57 | 58 | cfg, err := FromCommand(cmd) 59 | if tt.expectError { 60 | require.Error(t, err) 61 | require.Nil(t, cfg) 62 | } else { 63 | require.NoError(t, err) 64 | require.Equal(t, tt.expectedCfg, cfg) 65 | } 66 | }) 67 | } 68 | } 69 | 70 | func TestParseConfigFile(t *testing.T) { 71 | t.Parallel() 72 | 73 | testCases := []struct { 74 | name string 75 | fsContent map[string]string 76 | fileName string 77 | expectedResult *Config 78 | expectError bool 79 | }{ 80 | { 81 | name: "FileNotFound", 82 | fileName: "nonexistent.yaml", 83 | expectedResult: DefaultConfig(), 84 | }, 85 | { 86 | name: "InvalidYaml", 87 | fileName: "invalid.yaml", 88 | fsContent: map[string]string{"invalid.yaml": "invalid yaml content"}, 89 | expectError: true, 90 | }, 91 | { 92 | name: "DontIgnoreBranches", 93 | fileName: "dont_ignore_branches.yaml", 94 | fsContent: map[string]string{ 95 | "dont_ignore_branches.yaml": ` 96 | platform: linux/amd64 97 | ghactions: 98 | exclude_branches: 99 | `, 100 | }, 101 | expectedResult: &Config{ 102 | Platform: "linux/amd64", 103 | GHActions: GHActions{ 104 | Filter: Filter{ 105 | ExcludeBranches: []string{}, 106 | }, 107 | }, 108 | Images: Images{ 109 | ImageFilter: ImageFilter{ 110 | ExcludeImages: []string{"scratch"}, 111 | ExcludeTags: []string{"latest"}, 112 | }, 113 | }, 114 | }, 115 | }, 116 | { 117 | name: "ValidYaml", 118 | fileName: "valid.yaml", 119 | fsContent: map[string]string{ 120 | "valid.yaml": ` 121 | platform: linux/amd64 122 | ghactions: 123 | exclude: 124 | - pattern1 125 | - pattern2 126 | images: 127 | exclude_images: 128 | - notthisone 129 | exclude_tags: 130 | - notthistag 131 | `, 132 | }, 133 | expectedResult: &Config{ 134 | Platform: "linux/amd64", 135 | GHActions: GHActions{ 136 | Filter: Filter{ 137 | Exclude: []string{"pattern1", "pattern2"}, 138 | ExcludeBranches: []string{"main", "master"}, 139 | }, 140 | }, 141 | Images: Images{ 142 | ImageFilter: ImageFilter{ 143 | ExcludeImages: []string{"notthisone"}, 144 | ExcludeTags: []string{"notthistag"}, 145 | }, 146 | }, 147 | }, 148 | }, 149 | { 150 | name: "EmptyFile", 151 | fileName: "empty.yaml", 152 | fsContent: map[string]string{"empty.yaml": ""}, 153 | expectedResult: DefaultConfig(), 154 | }, 155 | } 156 | 157 | for _, tt := range testCases { 158 | t.Run(tt.name, func(t *testing.T) { 159 | t.Parallel() 160 | 161 | fs := memfs.New() 162 | for name, content := range tt.fsContent { 163 | f, _ := fs.Create(name) 164 | _, _ = f.Write([]byte(content)) 165 | require.NoError(t, f.Close()) 166 | } 167 | 168 | cfg, err := ParseConfigFileFromFS(fs, tt.fileName) 169 | if tt.expectError { 170 | require.Error(t, err) 171 | } else { 172 | require.NoError(t, err) 173 | require.Equal(t, tt.expectedResult.Platform, cfg.Platform) 174 | if cfg.GHActions.Exclude != nil { 175 | require.Equal(t, tt.expectedResult.GHActions.Exclude, cfg.GHActions.Exclude) 176 | } 177 | if cfg.Images.ExcludeImages != nil { 178 | require.Equal(t, tt.expectedResult.Images.ExcludeImages, cfg.Images.ExcludeImages) 179 | } 180 | if cfg.Images.ExcludeTags != nil { 181 | require.Equal(t, tt.expectedResult.Images.ExcludeTags, cfg.Images.ExcludeTags) 182 | } 183 | if cfg.GHActions.ExcludeBranches != nil { 184 | require.Equal(t, tt.expectedResult.GHActions.ExcludeBranches, cfg.GHActions.ExcludeBranches) 185 | } 186 | } 187 | }) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /pkg/utils/ghrest/ghrest.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package ghrest provides a wrapper around the go-github client that implements the internal REST API 17 | package ghrest 18 | 19 | import ( 20 | "bytes" 21 | "context" 22 | "io" 23 | "net/http" 24 | 25 | "github.com/google/go-github/v66/github" 26 | ) 27 | 28 | // Client is the struct that contains the GitHub REST API client 29 | // this struct implements the REST API 30 | type Client struct { 31 | client *github.Client 32 | } 33 | 34 | // NewClient creates a new instance of GhRest 35 | func NewClient(token string) *Client { 36 | ghcli := github.NewClient(nil) 37 | 38 | if token != "" { 39 | ghcli = ghcli.WithAuthToken(token) 40 | } 41 | return &Client{ 42 | client: ghcli, 43 | } 44 | } 45 | 46 | // NewRequest creates an API request. A relative URL can be provided in urlStr, 47 | // which will be resolved to the BaseURL of the Client. Relative URLS should 48 | // always be specified without a preceding slash. If specified, the value 49 | // pointed to by body is JSON encoded and included as the request body. 50 | func (c *Client) NewRequest(method, requestUrl string, body any) (*http.Request, error) { 51 | return c.client.NewRequest(method, requestUrl, body) 52 | } 53 | 54 | // Do sends an API request and returns the API response. 55 | func (c *Client) Do(ctx context.Context, req *http.Request) (*http.Response, error) { 56 | var buf bytes.Buffer 57 | 58 | // The GitHub client closes the response body, so we need to capture it 59 | // in a buffer so that we can return it to the caller 60 | resp, err := c.client.Do(ctx, req, &buf) 61 | if err != nil && resp == nil { 62 | return nil, err 63 | } 64 | 65 | if resp.Response != nil { 66 | resp.Response.Body = io.NopCloser(&buf) 67 | } 68 | 69 | return resp.Response, err 70 | } 71 | -------------------------------------------------------------------------------- /pkg/utils/ghrest/ghrest_test.go: -------------------------------------------------------------------------------- 1 | package ghrest 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "gopkg.in/h2non/gock.v1" 13 | ) 14 | 15 | // nolint:gocyclo 16 | func TestClientFunctions(t *testing.T) { 17 | t.Parallel() 18 | 19 | testCases := []struct { 20 | name string 21 | token string 22 | method string 23 | url string 24 | mockResponse *gock.Response 25 | expectedMethod string 26 | expectedURL string 27 | expectError bool 28 | expectedStatus int 29 | expectedBody string 30 | }{ 31 | { 32 | name: "NewClient", 33 | token: "test_token", 34 | expectedMethod: "", 35 | expectedURL: "", 36 | }, 37 | { 38 | name: "NewRequest GET", 39 | token: "", 40 | method: "GET", 41 | url: "test_url", 42 | expectedMethod: http.MethodGet, 43 | expectedURL: "https://api.github.com/test_url", 44 | }, 45 | { 46 | name: "Do successful request", 47 | token: "", 48 | method: "GET", 49 | url: "test", 50 | mockResponse: gock.New("https://api.github.com").Get("/test").Reply(200).BodyString(`{"message": "hello world"}`), 51 | expectedMethod: http.MethodGet, 52 | expectedURL: "https://api.github.com/test", 53 | expectedStatus: http.StatusOK, 54 | expectedBody: `{"message": "hello world"}`, 55 | }, 56 | { 57 | name: "Do failed request", 58 | token: "", 59 | method: "GET", 60 | url: "test", 61 | mockResponse: gock.New("https://api.github.com").Get("/test").ReplyError(errors.New("failed request")), 62 | expectedMethod: http.MethodGet, 63 | expectedURL: "https://api.github.com/test", 64 | expectError: true, 65 | }, 66 | } 67 | 68 | for _, tt := range testCases { 69 | if tt.mockResponse != nil { 70 | defer gock.Off() 71 | //gock.DisableNetworking() 72 | //t.Logf("Mock response configured for %s %s", tt.method, tt.url) 73 | } 74 | 75 | client := NewClient(tt.token) 76 | 77 | if tt.name == "NewClient" { 78 | assert.NotNil(t, client, "NewClient returned nil") 79 | assert.NotNil(t, client.client, "NewClient returned client with nil GitHub client") 80 | return 81 | } 82 | 83 | req, err := client.NewRequest(tt.method, tt.url, nil) 84 | require.NoError(t, err) 85 | require.Equal(t, req.Method, tt.expectedMethod) 86 | require.Equal(t, req.URL.String(), tt.expectedURL) 87 | 88 | if tt.name == "NewRequest GET" { 89 | return 90 | } 91 | 92 | ctx := context.Background() 93 | 94 | resp, err := client.Do(ctx, req) 95 | if tt.expectError { 96 | require.NotNil(t, err, "Expected error, got nil") 97 | require.Nil(t, resp, "Expected nil response, got %v", resp) 98 | return 99 | } 100 | require.Nil(t, err, "Expected no error, got %v", err) 101 | 102 | require.Equal(t, resp.StatusCode, tt.expectedStatus) 103 | 104 | body, err := io.ReadAll(resp.Body) 105 | require.Nil(t, err) 106 | require.Equal(t, string(body), tt.expectedBody) 107 | defer resp.Body.Close() // nolint:errcheck 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /pkg/utils/store/cache.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Stacklok, Inc. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | // Package store provides utilities to work with a cache store. 17 | package store 18 | 19 | import ( 20 | "github.com/puzpuzpuz/xsync" 21 | ) 22 | 23 | // RefCacher is an interface for caching references. 24 | type RefCacher interface { 25 | Store(key, value string) 26 | Load(key string) (string, bool) 27 | } 28 | 29 | type refCacher struct { 30 | cache *xsync.MapOf[string, string] 31 | } 32 | 33 | // NewRefCacher returns a new RefCacher. The default implementation is 34 | // thread-safe. 35 | func NewRefCacher() RefCacher { 36 | return &refCacher{ 37 | cache: xsync.NewMapOf[string](), 38 | } 39 | } 40 | 41 | // Store stores a key-value pair. 42 | func (r *refCacher) Store(key, value string) { 43 | r.cache.Store(key, value) 44 | } 45 | 46 | // Load loads a value for a given key. 47 | func (r *refCacher) Load(key string) (string, bool) { 48 | return r.cache.Load(key) 49 | } 50 | 51 | type unsafeCacher struct { 52 | cache map[string]string 53 | } 54 | 55 | // NewUnsafeCacher returns a new RefCacher that's not thread-safe. 56 | func NewUnsafeCacher() RefCacher { 57 | return &unsafeCacher{ 58 | cache: map[string]string{}, 59 | } 60 | } 61 | 62 | // Store stores a key-value pair. 63 | func (r *unsafeCacher) Store(key, value string) { 64 | r.cache[key] = value 65 | } 66 | 67 | // Load loads a value for a given key. 68 | func (r *unsafeCacher) Load(key string) (string, bool) { 69 | v, ok := r.cache[key] 70 | return v, ok 71 | } 72 | -------------------------------------------------------------------------------- /pkg/utils/store/cache_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | type Cacher interface { 11 | Store(key, value string) 12 | Load(key string) (string, bool) 13 | } 14 | 15 | // TestCacher tests the creation and basic functionality of both refCacher and unsafeCacher. 16 | func TestCacher(t *testing.T) { 17 | t.Parallel() 18 | 19 | testCases := []struct { 20 | name string 21 | cacher Cacher 22 | key string 23 | storeValue string 24 | loadKey string 25 | expectedVal string 26 | expectFound bool 27 | }{ 28 | { 29 | name: "RefCacher store and load existing key", 30 | cacher: NewRefCacher(), 31 | key: "key1", 32 | storeValue: "value1", 33 | loadKey: "key1", 34 | expectedVal: "value1", 35 | expectFound: true, 36 | }, 37 | { 38 | name: "RefCacher load non-existing key", 39 | cacher: NewRefCacher(), 40 | key: "key1", 41 | storeValue: "value1", 42 | loadKey: "key2", 43 | expectedVal: "", 44 | expectFound: false, 45 | }, 46 | { 47 | name: "UnsafeCacher store and load existing key", 48 | cacher: NewUnsafeCacher(), 49 | key: "key1", 50 | storeValue: "value1", 51 | loadKey: "key1", 52 | expectedVal: "value1", 53 | expectFound: true, 54 | }, 55 | { 56 | name: "UnsafeCacher load non-existing key", 57 | cacher: NewUnsafeCacher(), 58 | key: "key1", 59 | storeValue: "value1", 60 | loadKey: "key2", 61 | expectedVal: "", 62 | expectFound: false, 63 | }, 64 | } 65 | 66 | for _, tt := range testCases { 67 | t.Run(tt.name, func(t *testing.T) { 68 | t.Parallel() 69 | 70 | tt.cacher.Store(tt.key, tt.storeValue) 71 | val, ok := tt.cacher.Load(tt.loadKey) 72 | require.Equal(t, tt.expectFound, ok) 73 | require.Equal(t, tt.expectedVal, val) 74 | }) 75 | } 76 | } 77 | 78 | // TestConcurrency tests the thread-safety of refCacher. 79 | func TestConcurrency(t *testing.T) { 80 | t.Parallel() 81 | 82 | cacher := NewRefCacher() 83 | iterations := 1000 84 | done := make(chan bool) 85 | 86 | // Concurrently store values 87 | for i := 0; i < iterations; i++ { 88 | go func(i int) { 89 | key := fmt.Sprintf("key%d", i) 90 | value := fmt.Sprintf("value%d", i) 91 | cacher.Store(key, value) 92 | done <- true 93 | }(i) 94 | } 95 | 96 | // Wait for all goroutines to finish storing 97 | for i := 0; i < iterations; i++ { 98 | <-done 99 | } 100 | 101 | // Concurrently load values 102 | for i := 0; i < iterations; i++ { 103 | go func(i int) { 104 | key := fmt.Sprintf("key%d", i) 105 | val, ok := cacher.Load(key) 106 | expectedVal := fmt.Sprintf("value%d", i) 107 | require.True(t, ok) 108 | require.Equal(t, expectedVal, val) 109 | done <- true 110 | }(i) 111 | } 112 | 113 | // Wait for all goroutines to finish loading 114 | for i := 0; i < iterations; i++ { 115 | <-done 116 | } 117 | } 118 | --------------------------------------------------------------------------------