├── .devcontainer ├── cache │ ├── go │ │ └── .gitkeep │ └── helm │ │ └── .gitkeep ├── cosign.key ├── cosign.pub ├── devcontainer.json └── docker-compose.yml ├── .github ├── release-drafter.yml └── workflows │ ├── build.yml │ ├── deploy-docs.yml │ ├── release-drafter.yml │ ├── release.yml │ └── test-deploy-docs.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .vscode └── launch.json ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MAINTAINERS.md ├── README.md ├── REVIEWING.md ├── SECURITY.md ├── cmd └── helmper │ └── main.go ├── docs ├── gifs │ ├── full.gif │ └── simple.gif ├── imgs │ └── reporting_a_security_concern-bd9875c888ca5bfea8008e5904b12cf1.png └── logo │ ├── helmper.svg │ ├── helmper_banner.png │ └── helmper_banner.svg ├── example └── helmper.yaml ├── go.mod ├── go.sum ├── internal ├── bootstrap │ ├── helm.go │ ├── logger.go │ └── viper.go ├── program.go └── program_test.go ├── pkg ├── copa │ ├── doc.go │ ├── main.go │ └── patch.go ├── cosign │ ├── chart.go │ ├── doc.go │ ├── error.go │ ├── image.go │ ├── verify.go │ └── verifyChart.go ├── exportArtifacts │ ├── main.go │ └── main_test.go ├── flow │ ├── doc.go │ └── spsOption.go ├── helm │ ├── OCIRegistry.go │ ├── chart.go │ ├── chartCollection.go │ ├── chartImportOption.go │ ├── chartOption.go │ ├── chart_test.go │ ├── chart_version.go │ ├── chart_version_test.go │ ├── doc.go │ ├── indexFileLoader.go │ ├── option.go │ ├── parser.go │ ├── parser_test.go │ ├── registryClient.go │ ├── types.go │ └── update.go ├── image │ ├── doc.go │ ├── image.go │ └── image_test.go ├── registry │ ├── doc.go │ ├── importOption.go │ └── registry.go ├── report │ ├── doc.go │ ├── types.go │ └── util.go ├── trivy │ ├── doc.go │ ├── main.go │ ├── util.go │ └── util_test.go └── util │ ├── bar │ └── main.go │ ├── counter │ ├── counter.go │ └── counter_test.go │ ├── file │ ├── fileop.go │ └── fileop_test.go │ ├── state │ ├── state.go │ └── state_test.go │ ├── terminal │ ├── output.go │ ├── output_test.go │ ├── terminal.go │ └── terminal_test.go │ └── ternary │ ├── ternary.go │ └── ternary_test.go └── website ├── README.md ├── babel.config.js ├── docs ├── auth.md ├── ci.md ├── compatibility.md ├── config.md ├── diagams │ ├── _category_.json │ ├── configoptions.md │ ├── er.md │ └── externalservices.md ├── env.md ├── install.md ├── intro.md ├── intro_extended.md ├── oci.md └── parser.md ├── docusaurus.config.ts ├── package-lock.json ├── package.json ├── sidebars.ts ├── src ├── components │ └── HomepageFeatures │ │ ├── index.tsx │ │ └── styles.module.css ├── css │ └── custom.css └── pages │ ├── how.md │ ├── index.module.css │ ├── index.tsx │ ├── what.md │ └── why.md ├── static ├── .nojekyll └── img │ ├── api.svg │ ├── core.svg │ ├── extended.svg │ ├── favicon.ico │ ├── helmper_banner.svg │ ├── helmper_banner_background.svg │ ├── helmper_logo.svg │ ├── puzzle.svg │ └── timer.svg └── tsconfig.json /.devcontainer/cache/go/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChristofferNissen/helmper/2d2493276edb74dda8d456d66eae4c8c871f8ccd/.devcontainer/cache/go/.gitkeep -------------------------------------------------------------------------------- /.devcontainer/cache/helm/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChristofferNissen/helmper/2d2493276edb74dda8d456d66eae4c8c871f8ccd/.devcontainer/cache/helm/.gitkeep -------------------------------------------------------------------------------- /.devcontainer/cosign.key: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED COSIGN PRIVATE KEY----- 2 | eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6 3 | OCwicCI6MX0sInNhbHQiOiJxODhpcjAvNDdLUjlsajJYYlNUYzkyaUhpcDZHZ3JV 4 | Y1RScXdQQjAwVDhNPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 5 | Iiwibm9uY2UiOiJWTWNaMlVLY0ZzSWFSNnV5RjR6clpTNW9EN2cybk5FQSJ9LCJj 6 | aXBoZXJ0ZXh0IjoiUmVpK1NYbHdLbGc4UHhhVnIzaFRVK2xPWkJzaXc3SnZyMElE 7 | aHRzcWU4cWhQY05aaG5iajNaN3NBeUdRSHc3WnprYjc1d3lEekFsci9UT1VJTjJS 8 | S2dybk9pdy9vM2s5YnhaZ00rOHEzMk5sNkM2UDQwWXc0VVVFOWozTU5xY3dXRnY1 9 | aUxTSC9yczk5VUxSUTd0Ni95WWZFYm41emM1OXQ3aUgvTFZhNERmbkk4SDc3MFh6 10 | bldxUE1IdHozK0NGYnBtVUQrM2k3Kzk3a3c9PSJ9 11 | -----END ENCRYPTED COSIGN PRIVATE KEY----- 12 | -------------------------------------------------------------------------------- /.devcontainer/cosign.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEi8GqFQUz4RccdB0yFQWY5lWAEyzK 3 | 4PsTYqXrN8YFIQL2ksj00VP9kC0IzVD+oc/PWYWYngOR1htXFdYhZhO2og== 4 | -----END PUBLIC KEY----- 5 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/go 3 | { 4 | "name": "Go", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | // "image": "mcr.microsoft.com/devcontainers/go:1.22", 7 | 8 | "runArgs": [ 9 | "--force-recreate", 10 | "--network=host", 11 | ], 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | 15 | "dockerComposeFile": "docker-compose.yml", 16 | "service": "devcontainer", 17 | "workspaceFolder": "/workspace", 18 | 19 | "mounts": [ 20 | { "source": "${localWorkspaceFolder}/.devcontainer/cache/helm/config", "target": "/home/vscode/.config/helm", "type": "bind" }, 21 | { "source": "${localWorkspaceFolder}/.devcontainer/cache/helm/cache", "target": "/home/vscode/.cache/helm", "type": "bind" }, 22 | { "source": "${localWorkspaceFolder}/.devcontainer/cache/go", "target": "/go", "type": "bind" }, 23 | { "source": "${localEnv:HOME}/.cache/go-build", "target": "/home/vscode/.cache/go-build", "type": "bind" } 24 | ], 25 | 26 | // Features to add to the dev container. More info: https://containers.dev/features. 27 | // "features": { 28 | // "ghcr.io/devcontainers/features/azure-cli:1": {}, 29 | // "ghcr.io/devcontainers/features/common-utils:2": {}, 30 | // "ghcr.io/devcontainers/features/git:1": {}, 31 | // "ghcr.io/devcontainers/features/go:1": {}, 32 | // "ghcr.io/guiyomh/features/goreleaser:0": {} 33 | // }, 34 | 35 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 36 | // "forwardPorts": [], 37 | 38 | // Use 'postCreateCommand' to run commands after the container is created. 39 | // "postCreateCommand": "go version", 40 | 41 | // Configure tool-specific properties. 42 | // "customizations": {}, 43 | 44 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 45 | // "remoteUser": "root" 46 | } 47 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | devcontainer: 4 | image: mcr.microsoft.com/devcontainers/go:1.22 5 | volumes: 6 | - ..:/workspace:cached 7 | network_mode: "host" 8 | command: sleep infinity 9 | 10 | buildkitd: 11 | image: moby/buildkit:v0.15.1 12 | entrypoint: ["buildkitd"] 13 | command: ["--addr", "tcp://0.0.0.0:8888"] 14 | privileged: true 15 | ports: 16 | - 8888:8888/tcp 17 | 18 | trivy: 19 | image: aquasec/trivy:0.50.4 20 | command: ["server", "--listen=0.0.0.0:8887"] 21 | ports: 22 | - 8887:8887 23 | 24 | registry: 25 | image: registry:2 26 | restart: always 27 | ports: 28 | - 5000:5000 29 | 30 | registry2: 31 | image: registry:2 32 | restart: always 33 | ports: 34 | - 5001:5000 35 | 36 | registry-ui: 37 | image: joxit/docker-registry-ui:main 38 | restart: always 39 | network_mode: "host" 40 | environment: 41 | - SINGLE_REGISTRY=true 42 | - REGISTRY_TITLE=Docker Registry UI 43 | - DELETE_IMAGES=true 44 | - SHOW_CONTENT_DIGEST=true 45 | - NGINX_PROXY_PASS_URL=http://localhost:5000 46 | - SHOW_CATALOG_NB_TAGS=true 47 | - CATALOG_MIN_BRANCHES=1 48 | - CATALOG_MAX_BRANCHES=1 49 | - TAGLIST_PAGE_SIZE=100 50 | - REGISTRY_SECURED=false 51 | - CATALOG_ELEMENTS_LIMIT=1000 52 | - NGINX_LISTEN_PORT=8080 53 | container_name: registry-ui 54 | 55 | registry2-ui: 56 | image: joxit/docker-registry-ui:main 57 | restart: always 58 | network_mode: "host" 59 | environment: 60 | - SINGLE_REGISTRY=true 61 | - REGISTRY_TITLE=Docker Registry UI 62 | - DELETE_IMAGES=true 63 | - SHOW_CONTENT_DIGEST=true 64 | - NGINX_PROXY_PASS_URL=http://localhost:5001 65 | - SHOW_CATALOG_NB_TAGS=true 66 | - CATALOG_MIN_BRANCHES=1 67 | - CATALOG_MAX_BRANCHES=1 68 | - TAGLIST_PAGE_SIZE=100 69 | - REGISTRY_SECURED=false 70 | - CATALOG_ELEMENTS_LIMIT=1000 71 | - NGINX_LISTEN_PORT=8081 72 | container_name: registry2-ui 73 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: 1.22.2 21 | - name: Cache Go modules 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/go/pkg/mod 25 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 26 | restore-keys: | 27 | ${{ runner.os }}-go- 28 | - name: Build 29 | run: | 30 | cd cmd/helmper/ 31 | go build 32 | - name: Tests 33 | run: | 34 | go test -v ./... 35 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | # Review gh actions docs if you want to further define triggers, paths, etc 8 | # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on 9 | 10 | jobs: 11 | build: 12 | name: Build Docusaurus 13 | runs-on: ubuntu-latest 14 | defaults: 15 | run: 16 | working-directory: ./website 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 18 24 | cache: npm 25 | cache-dependency-path: website/package-lock.json 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | - name: Build website 30 | run: npm run build 31 | 32 | - name: Upload Build Artifact 33 | uses: actions/upload-pages-artifact@v3 34 | with: 35 | path: ./website/build 36 | 37 | deploy: 38 | name: Deploy to GitHub Pages 39 | needs: build 40 | defaults: 41 | run: 42 | working-directory: ./website 43 | 44 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 45 | permissions: 46 | pages: write # to deploy to Pages 47 | id-token: write # to verify the deployment originates from an appropriate source 48 | 49 | # Deploy to the github-pages environment 50 | environment: 51 | name: github-pages 52 | url: ${{ steps.deployment.outputs.page_url }} 53 | 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: release-drafter/release-drafter@v6 14 | with: 15 | config-name: release-drafter.yml 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.HELMPER_GORELEASER_GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Clean up disk space 19 | run: | 20 | sudo rm -rf /usr/share/dotnet 21 | sudo rm -rf /opt/ghc 22 | sudo rm -rf "/usr/local/share/boost" 23 | sudo rm -rf "$AGENT_TOOLSDIRECTORY" 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: 1.22.2 28 | - name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@v5 30 | with: 31 | distribution: goreleaser 32 | version: latest 33 | args: release --clean 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.HELMPER_GORELEASER_GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/test-deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Test deployment 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | # Review gh actions docs if you want to further define triggers, paths, etc 8 | # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on 9 | 10 | jobs: 11 | test-deploy: 12 | name: Test deployment 13 | runs-on: ubuntu-latest 14 | defaults: 15 | run: 16 | working-directory: ./website 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 18 24 | cache: npm 25 | cache-dependency-path: website/package-lock.json 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | - name: Test build website 30 | run: npm run build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.devcontainer/cache/go/bin/** 2 | **/.devcontainer/cache/go/pkg/** 3 | **/.devcontainer/cache/helm/cache/** 4 | **/.devcontainer/cache/helm/config/** 5 | 6 | **/.out 7 | **/.in 8 | 9 | **/cache/go/*/ 10 | 11 | # Dependencies 12 | node_modules 13 | 14 | # Production 15 | /build 16 | 17 | # Generated files 18 | .docusaurus 19 | .cache-loader 20 | 21 | # Misc 22 | .DS_Store 23 | .env.local 24 | .env.development.local 25 | .env.test.local 26 | .env.production.local 27 | 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # Refer to golangci-lint's example config file for more options and information: 2 | # https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml 3 | 4 | run: 5 | timeout: 10m 6 | modules-download-mode: readonly 7 | 8 | linters: 9 | enable: 10 | - errcheck 11 | - goimports 12 | - revive 13 | - govet 14 | - staticcheck 15 | - sloglint 16 | 17 | issues: 18 | # exclude-use-default: false 19 | max-issues-per-linter: 0 20 | max-same-issues: 0 21 | 22 | linters-settings: 23 | sloglint: 24 | # Enforce not mixing key-value pairs and attributes. 25 | # Default: true 26 | no-mixed-args: false 27 | # Enforce using key-value pairs only (overrides no-mixed-args, incompatible with attr-only). 28 | # Default: false 29 | kv-only: true 30 | # Enforce using attributes only (overrides no-mixed-args, incompatible with kv-only). 31 | # Default: false 32 | attr-only: true 33 | # Enforce using methods that accept a context. 34 | # Default: false 35 | context-only: true 36 | # Enforce using static values for log messages. 37 | # Default: false 38 | static-msg: true 39 | # Enforce using constants instead of raw keys. 40 | # Default: false 41 | no-raw-keys: true 42 | # Enforce a single key naming convention. 43 | # Values: snake, kebab, camel, pascal 44 | # Default: "" 45 | key-naming-case: snake 46 | # Enforce putting arguments on separate lines. 47 | # Default: false 48 | args-on-sep-lines: true 49 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | 2 | builds: 3 | - 4 | main: ./cmd/helmper 5 | env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - darwin 10 | # - windows 11 | goarch: 12 | - amd64 13 | - arm64 14 | ldflags: 15 | - "-s -w -X github.com/ChristofferNissen/helmper/internal.version={{ .Version }} -X github.com/ChristofferNissen/helmper/internal.commit={{ .Commit }} -X github.com/ChristofferNissen/helmper/internal.date={{ .CommitDate }}" 16 | 17 | archives: 18 | - id: archives 19 | format: binary 20 | name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" 21 | 22 | checksum: 23 | name_template: "{{ .ProjectName }}-checksums.txt" 24 | 25 | snapshot: 26 | name_template: "git-{{.Commit}}" 27 | 28 | release: 29 | name_template: "v{{.Version}}" 30 | 31 | changelog: 32 | skip: true 33 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Launch Package", 5 | "type": "go", 6 | "request": "launch", 7 | "mode": "auto", 8 | "program": "cmd/helmper/main.go", 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ChristofferNissen 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Helmper follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). 4 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | General maintainers: 4 | 5 | * Christoffer Nissen (christoffer.nissen@gmail.com / @ChristofferNissen) 6 | * Aditya Sundaramurthy (aditya.sundaramurthy@gmail.com / @logicfox) 7 | -------------------------------------------------------------------------------- /REVIEWING.md: -------------------------------------------------------------------------------- 1 | # Reviewing Guide 2 | 3 | This document covers who may review pull requests for this project, and provides guidance on how to perform code reviews that meet our community standards and code of conduct. All reviewers must read this document and agree to follow the project review guidelines. Reviewers who do not follow these guidelines may have their privileges revoked. 4 | 5 | 6 | ## The Reviewer Role 7 | 8 | Everyone is encouraged to review code, even if they are not officially reviewer. However, only pull requests approved by maintainers can be merged. 9 | 10 | 11 | ## Values 12 | 13 | All reviewers must abide by the [Code of Conduct](CODE_OF_CONDUCT.md) and are also protected by it. A reviewer should not tolerate poor behavior and is encouraged to report any behavior that violates the Code of Conduct. All of our values listed above are distilled from our Code of Conduct. 14 | 15 | Below are concrete examples of how it applies to code review specifically: 16 | 17 | ### Inclusion 18 | 19 | Be welcoming and inclusive. You should proactively ensure that the author is successful. While any particular pull request may not ultimately be merged, overall we want people to have a great experience and be willing to contribute again. Answer the questions they didn't know to ask or offer concrete help when they appear stuck. 20 | 21 | ### Sustainability 22 | 23 | Avoid burnout by enforcing healthy boundaries. Here are some examples of how a reviewer is encouraged to act to take care of themselves: 24 | 25 | * Authors should meet baseline expectations when submitting a pull request, such as writing tests. 26 | * If your availability changes, you can step down from a pull request and have someone else assigned. 27 | * If interactions with an author are not following the code of conduct, close the PR and raise it up with your Code of Conduct committee or point of contact. It's not your job to coax people into behaving. 28 | 29 | ### Trust 30 | 31 | Be trustworthy. During a review, your actions both build and help maintain the trust that the community has placed in this project. Below are examples of ways that we build trust: 32 | 33 | * **Transparency** - If a pull request won't be merged, clearly say why and close it. If a pull request won't be reviewed for a while, let the author know so they can set expectations and understand why it's blocked. 34 | * **Integrity** - Put the project's best interests ahead of personal relationships or company affiliations when deciding if a change should be merged. 35 | * **Stability** - Only merge when the change won't negatively impact project stability. It can be tempting to merge a pull request that doesn't meet our quality standards, for example when the review has been delayed, or because we are trying to deliver new features quickly, but regressions can significantly hurt trust in our project. 36 | 37 | 38 | ## Process 39 | 40 | 1. Do not start reviewing a pull request if it is WIP or is a draft pull request until it is marked as **ready to review**. 41 | 2. Do not start reviewing a pull request if automated checks fail. Wait until they are fixed by the author. 42 | 3. All pull requests except those generated by bots MUST have an issue associated. Motivations and designs SHOULD be discussed in the corresponding issue instead of in the pull request. 43 | 4. Do a quick check for the license header for all new files. 44 | 5. When you provide feedback, make it clear if the change must be made for the pull request to be approved, or if it is just a suggestion. Mark suggestions with `nit`. 45 | 6. Reviews from maintainers are required. Only pull requests approved by maintainers can be merged. If the author is a maintainer, the pull request is required to be approved by other maintainers. 46 | 7. Maintainers can merge their own pull requests after being approved by other maintainers. 47 | 8. Pull requests are required to be reviewed again if updates come after the approval from maintainers. 48 | 49 | 50 | ## Checklist 51 | 52 | Below are a set of common questions that apply to all pull requests: 53 | 54 | - [ ] Is this PR targeting the correct branch? 55 | - [ ] Does the commit message provide an adequate description of the change? 56 | - [ ] Does the affected code have corresponding tests? 57 | - [ ] Are the changes well documented and recognizable by `godoc`? 58 | - [ ] Does this introduce breaking changes that would require an announcement or bumping the major version? 59 | - [ ] Has this PR passed the CI? 60 | 61 | ## Reading List 62 | 63 | Reviewers are encouraged to read the following articles for help with common reviewer tasks: 64 | 65 | * [The Art of Closing: How to closing an unfinished or rejected pull request](https://blog.jessfraz.com/post/the-art-of-closing/) 66 | * [Kindness and Code Reviews: Improving the Way We Give Feedback](https://product.voxmedia.com/2018/8/21/17549400/kindness-and-code-reviews-improving-the-way-we-give-feedback) 67 | * [Code Review Guidelines for Humans: Examples of good and back feedback](https://phauer.com/2018/code-review-guidelines/#code-reviews-guidelines-for-the-reviewer) -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Thank you for taking the time to report a security vulnerability. We would like to investigate every report thoroughly. 4 | 5 | ## Reporting a Vulnerability 6 | To report a security vulnerability, please follow these steps: 7 | 8 | ### Step 1 9 | 10 | Navigate to the appropriate reporsitory. 11 | 12 | ### Step 2 13 | 14 | Click on Security and then Report a vulnerability 15 | 16 | ![Screenshot](docs/imgs/reporting_a_security_concern-bd9875c888ca5bfea8008e5904b12cf1.png) 17 | 18 | ### Step 3 19 | 20 | You can fill in all the details of the vulnerability and click on Submit report. This report will be visible to only the maintainers (and anyone else required to look into the issue). 21 | 22 | **Note**: Please do not open a public issue describing the vulnerability. 23 | 24 | ## When Should You Submit a Report 25 | Please send us a report whenever you: 26 | 27 | * Think any of the ORAS projects have a potential security vulnerability. 28 | * Are uncertain if the vulnerability exists or how it might impact our projects. 29 | 30 | ## Evaluation 31 | The maintainers will acknowledge and analyze your report within 14 working days for high severity issues. 32 | 33 | Any vulnerability information you share with us, stays with the maintainers. We will only disclose the information that is required to resolve the problem. 34 | 35 | We will update you on the status of the report throughout. 36 | 37 | ## Fixing the issue 38 | Once a security vulnerability has been identified, the maintainers (contributors, if required) will work on finding a solution. The development and testing for the fix will happen in a private GitHub repository in order to prevent premature disclosure of the vulnerability. 39 | 40 | After the fix has been tested and deemed fit to be made public, the changes will be merged from the private GitHub repository to the appropriate public branches. All the necessary binaries will be built and published. 41 | 42 | -------------------------------------------------------------------------------- /cmd/helmper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "github.com/ChristofferNissen/helmper/internal" 8 | ) 9 | 10 | func main() { 11 | // invoke program and handle error 12 | err := internal.Program(os.Args[1:]) 13 | if err != nil { 14 | slog.Error(err.Error()) 15 | os.Exit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/gifs/full.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChristofferNissen/helmper/2d2493276edb74dda8d456d66eae4c8c871f8ccd/docs/gifs/full.gif -------------------------------------------------------------------------------- /docs/gifs/simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChristofferNissen/helmper/2d2493276edb74dda8d456d66eae4c8c871f8ccd/docs/gifs/simple.gif -------------------------------------------------------------------------------- /docs/imgs/reporting_a_security_concern-bd9875c888ca5bfea8008e5904b12cf1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChristofferNissen/helmper/2d2493276edb74dda8d456d66eae4c8c871f8ccd/docs/imgs/reporting_a_security_concern-bd9875c888ca5bfea8008e5904b12cf1.png -------------------------------------------------------------------------------- /docs/logo/helmper_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChristofferNissen/helmper/2d2493276edb74dda8d456d66eae4c8c871f8ccd/docs/logo/helmper_banner.png -------------------------------------------------------------------------------- /example/helmper.yaml: -------------------------------------------------------------------------------- 1 | k8s_version: 1.31.1 2 | verbose: true 3 | update: false 4 | all: false 5 | parser: 6 | disableImageDetection: false 7 | useCustomValues: false 8 | failOnMissingValues: false 9 | failOnMissingImages: false 10 | mirrors: 11 | - registry: docker.io 12 | mirror: example.azurecr.io/docker/ 13 | import: 14 | enabled: true 15 | architecture: "linux/amd64" 16 | replaceRegistryReferences: true 17 | copacetic: 18 | enabled: true 19 | ignoreErrors: true 20 | buildkitd: 21 | addr: tcp://0.0.0.0:8888 22 | CACertPath: "" 23 | certPath: "" 24 | keyPath: "" 25 | trivy: 26 | addr: http://0.0.0.0:8887 27 | insecure: true 28 | ignoreUnfixed: true 29 | output: 30 | tars: 31 | folder: /workspace/.out/tars 32 | clean: true 33 | reports: 34 | folder: /workspace/.out/reports 35 | clean: true 36 | cosign: 37 | enabled: true 38 | verifyExisting: false 39 | keyRef: /workspace/.devcontainer/cosign.key 40 | KeyRefPass: "" 41 | allowInsecure: true 42 | allowHTTPRegistry: true 43 | charts: 44 | - name: loki 45 | version: 5.38.0 46 | valuesFilePath: /workspace/.in/values/loki/values.yaml 47 | plainHTTP: false 48 | repo: 49 | name: grafana 50 | url: https://grafana.github.io/helm-charts/ 51 | username: "" 52 | password: "" 53 | certFile: "" 54 | keyFile: "" 55 | caFile: "" 56 | insecure_skip_tls_verify: false 57 | pass_credentials_all: false 58 | - name: kyverno 59 | version: 3.1.1 60 | valuesFilePath: /workspace/.in/values/kyverno/values.yaml 61 | repo: 62 | name: kyverno 63 | url: https://kyverno.github.io/kyverno/ 64 | - name: keda 65 | version: 2.11.2 66 | repo: 67 | name: kedacore 68 | url: https://kedacore.github.io/charts/ 69 | - name: argo-cd 70 | version: 5.51.4 71 | repo: 72 | name: argo 73 | url: https://argoproj.github.io/argo-helm/ 74 | images: 75 | exclude: 76 | - ref: ghcr.io/dexidp/dex 77 | excludeCopacetic: 78 | - ref: quay.io/argoproj/argocd 79 | modify: 80 | - from: quay.io/argoproj/argocd 81 | to: quay.io/argoproj/argocd 82 | - fromValuePath: global.image.repository 83 | to: quay.io/argoproj/argocd 84 | - name: prometheus 85 | version: 25.8.0 86 | valuesFilePath: /workspace/.in/values/prometheus/values.yaml 87 | repo: 88 | name: prometheus-community 89 | url: https://prometheus-community.github.io/helm-charts/ 90 | images: 91 | - ref: docker.io/library/helloworld:latest 92 | - ref: docker.io/library/redis:latest 93 | patch: false 94 | registries: 95 | - name: registry 96 | url: oci://0.0.0.0:5000 97 | insecure: true 98 | plainHTTP: true 99 | - name: registry 100 | url: oci://0.0.0.0:5001 101 | insecure: true 102 | plainHTTP: true 103 | sourcePrefix: true 104 | export: 105 | artifacts: 106 | enabled: true 107 | folder: /workspace/.out/artifacts -------------------------------------------------------------------------------- /internal/bootstrap/helm.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "github.com/ChristofferNissen/helmper/pkg/helm" 8 | "go.uber.org/fx" 9 | "helm.sh/helm/v3/pkg/cli" 10 | ) 11 | 12 | // EnvironmentSetter is a function type for setting environment variables 13 | type EnvironmentSetter func(key, value string) error 14 | 15 | var setEnv EnvironmentSetter = os.Setenv 16 | 17 | type ChartSetupper interface { 18 | SetupHelm(settings *cli.EnvSettings, setters ...helm.Option) (*helm.ChartCollection, error) 19 | } 20 | 21 | // Add Helm repos to user's local helm configuration file, Optionupdate all existing repos and pulls charts 22 | func SetupHelm(settings *cli.EnvSettings, charts ChartSetupper, setters ...helm.Option) (*helm.ChartCollection, error) { 23 | // Default Options 24 | args := &helm.Options{ 25 | Verbose: false, 26 | Update: false, 27 | K8SVersion: "1.31.1", 28 | } 29 | 30 | for _, setter := range setters { 31 | setter(args) 32 | } 33 | 34 | // Set up Helm action configuration 35 | if err := setEnv("HELM_EXPERIMENTAL_OCI", "1"); err != nil { 36 | slog.Error("Error setting OCI environment variable", slog.Any("error", err)) 37 | os.Exit(1) 38 | } 39 | 40 | return charts.SetupHelm( 41 | settings, 42 | helm.Update(args.Update), 43 | helm.Verbose(args.Verbose), 44 | ) 45 | } 46 | 47 | func ProvideHelmSettings() *cli.EnvSettings { 48 | return cli.New() 49 | } 50 | 51 | var HelmSettingsModule = fx.Provide(ProvideHelmSettings) 52 | -------------------------------------------------------------------------------- /internal/bootstrap/logger.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "os" 5 | 6 | "log/slog" 7 | 8 | "go.uber.org/fx" 9 | ) 10 | 11 | // ProvideLogger sets up the slog configuration 12 | func ProvideLogger() *slog.Logger { 13 | slogHandlerOpts := &slog.HandlerOptions{} 14 | 15 | if os.Getenv("HELMPER_LOG_LEVEL") == "DEBUG" { 16 | slogHandlerOpts.Level = slog.LevelDebug 17 | } 18 | 19 | logger := slog.New(slog.NewJSONHandler(os.Stdout, slogHandlerOpts)) 20 | 21 | // Set this logger as the default 22 | slog.SetDefault(logger) 23 | 24 | // Example log entries 25 | slog.Info("Application started") 26 | slog.Debug("Debugging application") 27 | 28 | return logger 29 | } 30 | 31 | var LoggerModule = fx.Provide(ProvideLogger) 32 | -------------------------------------------------------------------------------- /pkg/copa/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package copa is a local copy/mod of Copacetic Go SDKs patch function, adapted to OCI export and tar output. The package facilitates patching of operating system vulnerabilities of container images. 3 | 4 | Files: 5 | 6 | - patch.go 7 | 8 | # Temporary Copy/Paste of Copacetic code from v0.6.2 9 | 10 | These changes will be worked on with maintainers of Copacetic to cater for our usecase so we can go back to using the official lib 11 | 12 | */ 13 | 14 | package copa 15 | -------------------------------------------------------------------------------- /pkg/copa/main.go: -------------------------------------------------------------------------------- 1 | package copa 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "log" 8 | "log/slog" 9 | "strings" 10 | "time" 11 | 12 | "github.com/ChristofferNissen/helmper/pkg/image" 13 | "github.com/ChristofferNissen/helmper/pkg/registry" 14 | myBar "github.com/ChristofferNissen/helmper/pkg/util/bar" 15 | "github.com/aquasecurity/trivy/pkg/fanal/types" 16 | v1 "github.com/google/go-containerregistry/pkg/v1" 17 | v1_spec "github.com/opencontainers/image-spec/specs-go/v1" 18 | "github.com/project-copacetic/copacetic/pkg/buildkit" 19 | "oras.land/oras-go/v2" 20 | "oras.land/oras-go/v2/content/oci" 21 | "oras.land/oras-go/v2/registry/remote" 22 | "oras.land/oras-go/v2/registry/remote/auth" 23 | "oras.land/oras-go/v2/registry/remote/credentials" 24 | "oras.land/oras-go/v2/registry/remote/retry" 25 | ) 26 | 27 | type PatchOption struct { 28 | Data map[*registry.Registry]map[*image.Image]bool 29 | 30 | TarFolder string 31 | ReportFolder string 32 | 33 | Buildkit struct { 34 | Addr string 35 | CACertPath string 36 | CertPath string 37 | KeyPath string 38 | } 39 | 40 | IgnoreErrors bool 41 | Architecture *string 42 | } 43 | 44 | func (o PatchOption) Run(ctx context.Context, reportFilePaths map[*image.Image]string, outFilePaths map[*image.Image]string) error { 45 | size := func() int { 46 | size := 0 47 | for _, m := range o.Data { 48 | for _, b := range m { 49 | if b { 50 | size++ 51 | } 52 | } 53 | } 54 | return size 55 | }() 56 | 57 | if !(size > 0) { 58 | return nil 59 | } 60 | 61 | bar := myBar.New("Patching images...\r", size) 62 | 63 | seenImages := []image.Image{} 64 | for _, m := range o.Data { 65 | for i := range m { 66 | ref := i.String() 67 | 68 | if i.In(seenImages) { 69 | log.Printf("Already patched '%s', skipping...\n", ref) 70 | continue 71 | } 72 | // make sure we don't parse again 73 | seenImages = append(seenImages, *i) 74 | 75 | if err := Patch(ctx, 30*time.Minute, ref, reportFilePaths[i], i.Tag, "", "trivy", "openvex", "", o.IgnoreErrors, buildkit.Opts{ 76 | Addr: o.Buildkit.Addr, 77 | CACertPath: o.Buildkit.CACertPath, 78 | CertPath: o.Buildkit.CertPath, 79 | KeyPath: o.Buildkit.KeyPath, 80 | }, outFilePaths[i]); err != nil { 81 | return fmt.Errorf("error patching image %s :: %w ", ref, err) 82 | } 83 | 84 | _ = bar.Add(1) 85 | } 86 | } 87 | _ = bar.Finish() 88 | 89 | bar = myBar.New("Pushing images from tar...\r", size) 90 | for r, m := range o.Data { 91 | for i, b := range m { 92 | if b { 93 | name, _ := i.ImageName() 94 | 95 | store, err := oci.NewFromTar(ctx, outFilePaths[i]) 96 | if err != nil { 97 | return err 98 | } 99 | manifest, err := store.Resolve(ctx, i.Tag) 100 | if err != nil { 101 | return err 102 | } 103 | i.Digest = manifest.Digest.String() 104 | 105 | if r.PrefixSource { 106 | old := name 107 | name, _ = image.UpdateNameWithPrefixSource(i) 108 | slog.Info("registry has PrefixSource enabled", slog.String("old", old), slog.String("new", name)) 109 | } 110 | 111 | // Connect to a remote repository 112 | url, _ := strings.CutPrefix(r.URL, "oci://") 113 | repo, err := remote.NewRepository(url + "/" + name) 114 | if err != nil { 115 | return err 116 | } 117 | 118 | repo.PlainHTTP = r.PlainHTTP 119 | 120 | // Prepare authentication using Docker credentials 121 | storeOpts := credentials.StoreOptions{} 122 | credStore, err := credentials.NewStoreFromDocker(storeOpts) 123 | if err != nil { 124 | return err 125 | } 126 | repo.Client = &auth.Client{ 127 | Client: retry.DefaultClient, 128 | Cache: auth.NewCache(), 129 | Credential: credentials.Credential(credStore), // Use the credentials store 130 | } 131 | 132 | // Copy from the file store to the remote repository 133 | opts := oras.DefaultCopyOptions 134 | if o.Architecture != nil { 135 | v, err := v1.ParsePlatform(*o.Architecture) 136 | if err != nil { 137 | return err 138 | } 139 | opts.WithTargetPlatform( 140 | &v1_spec.Platform{ 141 | Architecture: v.Architecture, 142 | OS: v.OS, 143 | OSVersion: v.OSVersion, 144 | OSFeatures: v.OSFeatures, 145 | Variant: v.Variant, 146 | }, 147 | ) 148 | } 149 | manifest, err = oras.Copy(ctx, store, i.Tag, repo, i.Tag, opts) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | i.Digest = manifest.Digest.String() 155 | } 156 | _ = bar.Add(1) 157 | } 158 | } 159 | 160 | _ = bar.Finish() 161 | 162 | return nil 163 | } 164 | 165 | func SupportedOS(os *types.OS) bool { 166 | if os == nil { 167 | return true 168 | } 169 | 170 | switch os.Family { 171 | case "photon": 172 | return false 173 | default: 174 | return true 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /pkg/cosign/chart.go: -------------------------------------------------------------------------------- 1 | package cosign 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "strings" 8 | "time" 9 | 10 | "github.com/ChristofferNissen/helmper/pkg/helm" 11 | "github.com/ChristofferNissen/helmper/pkg/registry" 12 | "github.com/ChristofferNissen/helmper/pkg/util/bar" 13 | "github.com/google/go-containerregistry/pkg/authn" 14 | "github.com/google/go-containerregistry/pkg/v1/remote" 15 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" 16 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" 17 | "helm.sh/helm/v3/pkg/chartutil" 18 | "helm.sh/helm/v3/pkg/cli" 19 | 20 | _ "github.com/sigstore/sigstore/pkg/signature/kms/aws" 21 | _ "github.com/sigstore/sigstore/pkg/signature/kms/azure" 22 | _ "github.com/sigstore/sigstore/pkg/signature/kms/fake" 23 | _ "github.com/sigstore/sigstore/pkg/signature/kms/gcp" 24 | _ "github.com/sigstore/sigstore/pkg/signature/kms/hashivault" 25 | ) 26 | 27 | type SignChartOption struct { 28 | // ChartCollection *helm.ChartCollection 29 | // Registries []registry.Registry 30 | Data map[*registry.Registry]map[*helm.Chart]bool 31 | 32 | KeyRef string 33 | KeyRefPass string 34 | AllowInsecure bool 35 | AllowHTTPRegistry bool 36 | 37 | Settings *cli.EnvSettings 38 | } 39 | 40 | // cosignAdapter wraps the cosign CLIs native code 41 | func (so SignChartOption) Run() error { 42 | 43 | size := func() int { 44 | size := 0 45 | for _, m := range so.Data { 46 | for _, b := range m { 47 | if b { 48 | size++ 49 | } 50 | } 51 | } 52 | return size 53 | }() 54 | 55 | // Return early i no charts to sign, or no registries to upload signature to 56 | if !(size > 0) { 57 | slog.Debug("No charts or registries specified. Skipping signing charts...") 58 | return nil 59 | } 60 | 61 | if so.Settings == nil { 62 | so.Settings = cli.New() 63 | } 64 | 65 | bar := bar.New("Signing charts...\r", size) 66 | 67 | // Sign with cosign 68 | timeout := 2 * time.Minute 69 | ro := options.RootOptions{ 70 | Timeout: timeout, 71 | Verbose: false, 72 | } 73 | 74 | signOpts := options.SignOptions{ 75 | Key: so.KeyRef, 76 | Upload: true, 77 | TlogUpload: false, 78 | SkipConfirmation: true, 79 | 80 | Registry: options.RegistryOptions{ 81 | AllowInsecure: so.AllowInsecure, 82 | AllowHTTPRegistry: so.AllowHTTPRegistry, 83 | 84 | RegistryClientOpts: []remote.Option{ 85 | remote.WithAuthFromKeychain(authn.DefaultKeychain), 86 | remote.WithRetryBackoff(remote.Backoff{ 87 | Duration: 1 * time.Second, 88 | Jitter: 1.0, 89 | Factor: 2.0, 90 | Steps: 5, 91 | Cap: timeout, 92 | }), 93 | }, 94 | }, 95 | } 96 | 97 | oidcClientSecret, err := signOpts.OIDC.ClientSecret() 98 | if err != nil { 99 | return err 100 | } 101 | ko := options.KeyOpts{ 102 | KeyRef: signOpts.Key, 103 | PassFunc: func(bool) ([]byte, error) { return []byte(so.KeyRefPass), nil }, 104 | Sk: signOpts.SecurityKey.Use, 105 | Slot: signOpts.SecurityKey.Slot, 106 | FulcioURL: signOpts.Fulcio.URL, 107 | IDToken: signOpts.Fulcio.IdentityToken, 108 | FulcioAuthFlow: signOpts.Fulcio.AuthFlow, 109 | InsecureSkipFulcioVerify: signOpts.Fulcio.InsecureSkipFulcioVerify, 110 | RekorURL: signOpts.Rekor.URL, 111 | OIDCIssuer: signOpts.OIDC.Issuer, 112 | OIDCClientID: signOpts.OIDC.ClientID, 113 | OIDCClientSecret: oidcClientSecret, 114 | OIDCRedirectURL: signOpts.OIDC.RedirectURL, 115 | OIDCDisableProviders: signOpts.OIDC.DisableAmbientProviders, 116 | OIDCProvider: signOpts.OIDC.Provider, 117 | SkipConfirmation: signOpts.SkipConfirmation, 118 | TSAClientCACert: signOpts.TSAClientCACert, 119 | TSAClientCert: signOpts.TSAClientCert, 120 | TSAClientKey: signOpts.TSAClientKey, 121 | TSAServerName: signOpts.TSAServerName, 122 | TSAServerURL: signOpts.TSAServerURL, 123 | IssueCertificateForExistingKey: signOpts.IssueCertificate, 124 | } 125 | 126 | for r, m := range so.Data { 127 | refs := []string{} 128 | for c, b := range m { 129 | if !b { 130 | continue 131 | } 132 | 133 | name := fmt.Sprintf("%s/%s", chartutil.ChartsDir, c.Name) 134 | d, err := r.Fetch(context.TODO(), name, c.Version) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | url, _ := strings.CutPrefix(r.URL, "oci://") 140 | url = strings.Replace(url, "0.0.0.0", "localhost", 1) 141 | ref := fmt.Sprintf("%s/%s/%s@%s", url, chartutil.ChartsDir, c.Name, d.Digest) 142 | refs = append(refs, ref) 143 | } 144 | 145 | // bar.ChangeMax(size + len(refs) - 1) 146 | if err := sign.SignCmd(&ro, ko, signOpts, refs); err != nil { 147 | return err 148 | } 149 | _ = bar.Add(len(refs)) 150 | } 151 | 152 | _ = bar.Finish() 153 | 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /pkg/cosign/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package Cosign is a convenience adapter for the Cosign Go SDK. The package facilitates signing and pushing of signatures of signed container images to container registries. 3 | */ 4 | 5 | package cosign 6 | -------------------------------------------------------------------------------- /pkg/cosign/error.go: -------------------------------------------------------------------------------- 1 | package cosign 2 | 3 | import ( 4 | "errors" 5 | 6 | cosignError "github.com/sigstore/cosign/v2/cmd/cosign/errors" 7 | ) 8 | 9 | // isNoMatchingSignatureErr checks if the error is of type ErrNoMatchingSignature 10 | func isNoMatchingSignatureErr(err error) bool { 11 | var ce *cosignError.CosignError 12 | if errors.As(err, &ce) && ce.Code == cosignError.NoMatchingSignature { 13 | return true 14 | } 15 | return false 16 | } 17 | 18 | // isImageWithoutSignatureErr checks if the error is of type ErrNoSignaturesFound 19 | func isImageWithoutSignatureErr(err error) bool { 20 | var ce *cosignError.CosignError 21 | if errors.As(err, &ce) && ce.Code == cosignError.ImageWithoutSignature { 22 | return true 23 | } 24 | return false 25 | } 26 | 27 | // isNoCertificateFoundOnSignatureErr checks if the error is of type ErrNoCertificateFoundOnSignature 28 | func isNoCertificateFoundOnSignatureErr(err error) bool { 29 | var ce *cosignError.CosignError 30 | if errors.As(err, &ce) && ce.Code == cosignError.NoCertificateFoundOnSignature { 31 | return true 32 | } 33 | return false 34 | } 35 | -------------------------------------------------------------------------------- /pkg/cosign/image.go: -------------------------------------------------------------------------------- 1 | package cosign 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "log/slog" 8 | "strings" 9 | "time" 10 | 11 | "github.com/ChristofferNissen/helmper/pkg/image" 12 | "github.com/ChristofferNissen/helmper/pkg/registry" 13 | "github.com/ChristofferNissen/helmper/pkg/util/bar" 14 | "github.com/google/go-containerregistry/pkg/authn" 15 | "github.com/google/go-containerregistry/pkg/v1/remote" 16 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" 17 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" 18 | 19 | _ "github.com/sigstore/sigstore/pkg/signature/kms/aws" 20 | _ "github.com/sigstore/sigstore/pkg/signature/kms/azure" 21 | _ "github.com/sigstore/sigstore/pkg/signature/kms/fake" 22 | _ "github.com/sigstore/sigstore/pkg/signature/kms/gcp" 23 | _ "github.com/sigstore/sigstore/pkg/signature/kms/hashivault" 24 | ) 25 | 26 | type SignOption struct { 27 | Data map[*registry.Registry]map[*image.Image]bool 28 | 29 | KeyRef string 30 | KeyRefPass string 31 | AllowInsecure bool 32 | AllowHTTPRegistry bool 33 | } 34 | 35 | // SignOption wraps the cosign CLIs native code 36 | func (so SignOption) Run(ctx context.Context) error { 37 | 38 | // count number of images 39 | size := func() int { 40 | i := 0 41 | for _, m := range so.Data { 42 | for _, b := range m { 43 | if b { 44 | i++ 45 | } 46 | } 47 | } 48 | return i 49 | }() 50 | 51 | // Return early i no images to sign, or no registries to upload signature to 52 | if !(size > 0) { 53 | slog.Debug("No images or registries specified. Skipping signing images...") 54 | return nil 55 | } 56 | 57 | bar := bar.New("Signing images...\r", size) 58 | 59 | // Sign with cosign 60 | timeout := 2 * time.Minute 61 | ro := options.RootOptions{ 62 | Timeout: timeout, 63 | Verbose: false, 64 | } 65 | 66 | signOpts := options.SignOptions{ 67 | Key: so.KeyRef, 68 | 69 | Upload: true, 70 | TlogUpload: false, 71 | SkipConfirmation: true, 72 | 73 | Registry: options.RegistryOptions{ 74 | AllowInsecure: so.AllowInsecure, 75 | AllowHTTPRegistry: so.AllowHTTPRegistry, 76 | 77 | RegistryClientOpts: []remote.Option{ 78 | remote.WithAuthFromKeychain(authn.DefaultKeychain), 79 | remote.WithRetryBackoff(remote.Backoff{ 80 | Duration: 1 * time.Second, 81 | Jitter: 1.0, 82 | Factor: 2.0, 83 | Steps: 5, 84 | Cap: timeout, 85 | }), 86 | }, 87 | }, 88 | } 89 | oidcClientSecret, err := signOpts.OIDC.ClientSecret() 90 | if err != nil { 91 | return err 92 | } 93 | ko := options.KeyOpts{ 94 | KeyRef: signOpts.Key, 95 | PassFunc: func(bool) ([]byte, error) { return []byte(so.KeyRefPass), nil }, 96 | Sk: signOpts.SecurityKey.Use, 97 | Slot: signOpts.SecurityKey.Slot, 98 | FulcioURL: signOpts.Fulcio.URL, 99 | IDToken: signOpts.Fulcio.IdentityToken, 100 | FulcioAuthFlow: signOpts.Fulcio.AuthFlow, 101 | InsecureSkipFulcioVerify: signOpts.Fulcio.InsecureSkipFulcioVerify, 102 | RekorURL: signOpts.Rekor.URL, 103 | OIDCIssuer: signOpts.OIDC.Issuer, 104 | OIDCClientID: signOpts.OIDC.ClientID, 105 | OIDCClientSecret: oidcClientSecret, 106 | OIDCRedirectURL: signOpts.OIDC.RedirectURL, 107 | OIDCDisableProviders: signOpts.OIDC.DisableAmbientProviders, 108 | OIDCProvider: signOpts.OIDC.Provider, 109 | SkipConfirmation: signOpts.SkipConfirmation, 110 | TSAClientCACert: signOpts.TSAClientCACert, 111 | TSAClientCert: signOpts.TSAClientCert, 112 | TSAClientKey: signOpts.TSAClientKey, 113 | TSAServerName: signOpts.TSAServerName, 114 | TSAServerURL: signOpts.TSAServerURL, 115 | IssueCertificateForExistingKey: signOpts.IssueCertificate, 116 | } 117 | 118 | for r, m := range so.Data { 119 | refs := []string{} 120 | for i, b := range m { 121 | if b { 122 | name, _ := i.ImageName() 123 | if i.Digest == "" { 124 | d, err := r.Fetch(ctx, name, i.Tag) 125 | if err != nil { 126 | return err 127 | } 128 | i.Digest = d.Digest.String() 129 | } 130 | if r.PrefixSource { 131 | old := name 132 | name, _ = image.UpdateNameWithPrefixSource(i) 133 | slog.Info("registry has PrefixSource enabled", slog.String("old", old), slog.String("new", name)) 134 | } 135 | url, _ := strings.CutPrefix(r.URL, "oci://") 136 | url = strings.Replace(url, "0.0.0.0", "localhost", 1) 137 | ref := fmt.Sprintf("%s/%s@%s", url, name, i.Digest) 138 | refs = append(refs, ref) 139 | } 140 | } 141 | if err := sign.SignCmd(&ro, ko, signOpts, refs); err != nil { 142 | return err 143 | } 144 | _ = bar.Add(len(refs)) 145 | } 146 | 147 | _ = bar.Finish() 148 | 149 | return nil 150 | } 151 | -------------------------------------------------------------------------------- /pkg/cosign/verify.go: -------------------------------------------------------------------------------- 1 | package cosign 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "log/slog" 8 | "strings" 9 | "time" 10 | 11 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 12 | "github.com/ChristofferNissen/helmper/pkg/image" 13 | "github.com/ChristofferNissen/helmper/pkg/registry" 14 | "github.com/ChristofferNissen/helmper/pkg/report" 15 | "github.com/ChristofferNissen/helmper/pkg/util/bar" 16 | "github.com/ChristofferNissen/helmper/pkg/util/counter" 17 | "github.com/ChristofferNissen/helmper/pkg/util/terminal" 18 | "github.com/google/go-containerregistry/pkg/authn" 19 | "github.com/google/go-containerregistry/pkg/v1/remote" 20 | "github.com/jedib0t/go-pretty/v6/table" 21 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" 22 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/verify" 23 | ) 24 | 25 | type VerifyOption struct { 26 | Data map[*registry.Registry]map[*image.Image]bool 27 | VerifyExisting bool 28 | 29 | KeyRef string 30 | AllowInsecure bool 31 | AllowHTTPRegistry bool 32 | 33 | Report *report.Table 34 | } 35 | 36 | // VerifyOption wraps the cosign CLIs native code 37 | func (vo *VerifyOption) Run(ctx context.Context) (map[*registry.Registry]map[*image.Image]bool, error) { 38 | 39 | if vo.Report == nil { 40 | vo.Report = report.NewTable("Signature Overview For Images") 41 | } 42 | 43 | var sc counter.SafeCounter = counter.NewSafeCounter() 44 | 45 | header := table.Row{"#", "Image"} 46 | 47 | size := func() int { 48 | size := 0 49 | for _, m := range vo.Data { 50 | for _, b := range m { 51 | if b || vo.VerifyExisting { 52 | size++ 53 | } 54 | } 55 | } 56 | return size 57 | }() 58 | 59 | // Return early: no images to sign, or no registries to upload signature to 60 | if !(size > 0) { 61 | slog.Debug("No images or registries specified. Skipping verifying images...") 62 | return make(map[*registry.Registry]map[*image.Image]bool), nil 63 | } 64 | 65 | bar := bar.New("Verifying signatures...\r", size) 66 | 67 | o := &options.VerifyOptions{ 68 | Key: vo.KeyRef, 69 | CheckClaims: true, 70 | Output: "", 71 | CommonVerifyOptions: options.CommonVerifyOptions{ 72 | IgnoreTlog: true, 73 | PrivateInfrastructure: true, 74 | ExperimentalOCI11: true, 75 | }, 76 | Registry: options.RegistryOptions{ 77 | AllowInsecure: vo.AllowInsecure, 78 | AllowHTTPRegistry: vo.AllowHTTPRegistry, 79 | 80 | RegistryClientOpts: []remote.Option{ 81 | remote.WithAuthFromKeychain(authn.DefaultKeychain), 82 | remote.WithRetryBackoff(remote.Backoff{ 83 | Duration: 1 * time.Second, 84 | Jitter: 1.0, 85 | Factor: 2.0, 86 | Steps: 5, 87 | Cap: 1 * time.Minute, 88 | }), 89 | }, 90 | }, 91 | } 92 | 93 | annotations, err := o.AnnotationsMap() 94 | if err != nil { 95 | return make(map[*registry.Registry]map[*image.Image]bool), err 96 | } 97 | 98 | hashAlgorithm, err := o.SignatureDigest.HashAlgorithm() 99 | if err != nil { 100 | return make(map[*registry.Registry]map[*image.Image]bool), err 101 | } 102 | 103 | v := &verify.VerifyCommand{ 104 | RegistryOptions: o.Registry, 105 | CertVerifyOptions: o.CertVerify, 106 | CheckClaims: o.CheckClaims, 107 | KeyRef: o.Key, 108 | CertRef: o.CertVerify.Cert, 109 | CertChain: o.CertVerify.CertChain, 110 | CAIntermediates: o.CertVerify.CAIntermediates, 111 | CARoots: o.CertVerify.CARoots, 112 | CertGithubWorkflowTrigger: o.CertVerify.CertGithubWorkflowTrigger, 113 | CertGithubWorkflowSha: o.CertVerify.CertGithubWorkflowSha, 114 | CertGithubWorkflowName: o.CertVerify.CertGithubWorkflowName, 115 | CertGithubWorkflowRepository: o.CertVerify.CertGithubWorkflowRepository, 116 | CertGithubWorkflowRef: o.CertVerify.CertGithubWorkflowRef, 117 | IgnoreSCT: o.CertVerify.IgnoreSCT, 118 | SCTRef: o.CertVerify.SCT, 119 | Sk: o.SecurityKey.Use, 120 | Slot: o.SecurityKey.Slot, 121 | Output: o.Output, 122 | RekorURL: o.Rekor.URL, 123 | Attachment: o.Attachment, 124 | Annotations: annotations, 125 | HashAlgorithm: hashAlgorithm, 126 | SignatureRef: o.SignatureRef, 127 | PayloadRef: o.PayloadRef, 128 | LocalImage: o.LocalImage, 129 | Offline: o.CommonVerifyOptions.Offline, 130 | TSACertChainPath: o.CommonVerifyOptions.TSACertChainPath, 131 | IgnoreTlog: o.CommonVerifyOptions.IgnoreTlog, 132 | MaxWorkers: o.CommonVerifyOptions.MaxWorkers, 133 | ExperimentalOCI11: o.CommonVerifyOptions.ExperimentalOCI11, 134 | } 135 | 136 | keys := make([]string, 0) 137 | rows := make(map[string]*table.Row) 138 | 139 | m := make(map[*registry.Registry]map[*image.Image]bool, 0) 140 | for r, elem := range vo.Data { 141 | if elem == nil { 142 | elem = make(map[*image.Image]bool, 0) 143 | } 144 | 145 | // extend table for each registry 146 | rn := r.GetName() 147 | header = append(header, rn) 148 | 149 | for i, b := range elem { 150 | // add row to overview table 151 | ref := i.String() 152 | 153 | // Check for existing row for Chart Name 154 | row := rows[ref] 155 | if row == nil { 156 | row = to.Ptr(table.Row{sc.Value("index_import"), ref}) 157 | rows[ref] = row 158 | keys = append(keys, ref) 159 | } 160 | 161 | if b || vo.VerifyExisting { 162 | name, err := i.ImageName() 163 | if err != nil { 164 | return nil, err 165 | } 166 | if r.PrefixSource { 167 | old := name 168 | name, err = image.UpdateNameWithPrefixSource(i) 169 | if err != nil { 170 | return nil, err 171 | } 172 | slog.Info("registry has PrefixSource enabled", slog.String("old", old), slog.String("new", name)) 173 | } 174 | 175 | if !b { 176 | if i.Digest == "" { 177 | d, err := r.Fetch(ctx, name, i.Tag) 178 | if err != nil { 179 | return nil, err 180 | } 181 | i.Digest = d.Digest.String() 182 | } 183 | } 184 | 185 | out, err := terminal.CaptureOutput(func() error { 186 | url, _ := strings.CutPrefix(r.URL, "oci://") 187 | url = strings.Replace(url, "0.0.0.0", "localhost", 1) 188 | s := fmt.Sprintf("%s/%s@%s", url, name, i.Digest) 189 | return v.Exec(ctx, []string{s}) 190 | }) 191 | slog.Debug(out) 192 | 193 | if err != nil { 194 | switch { 195 | case isNoCertificateFoundOnSignatureErr(err): 196 | fallthrough 197 | case isNoMatchingSignatureErr(err): 198 | fallthrough 199 | case isImageWithoutSignatureErr(err): 200 | elem[i] = true 201 | default: 202 | return make(map[*registry.Registry]map[*image.Image]bool), err 203 | } 204 | } else { 205 | elem[i] = false 206 | } 207 | 208 | *row = append(*row, terminal.StatusEmoji(!elem[i])) 209 | sc.Inc("index_import") 210 | _ = bar.Add(1) 211 | } 212 | 213 | } 214 | m[r] = elem 215 | } 216 | 217 | // Output table 218 | for _, k := range keys { 219 | valP := rows[k] 220 | if valP != nil { 221 | vo.Report.AddRow(*valP) 222 | } 223 | } 224 | vo.Report.AddHeader(header) 225 | 226 | _ = bar.Finish() 227 | 228 | return m, nil 229 | } 230 | -------------------------------------------------------------------------------- /pkg/cosign/verifyChart.go: -------------------------------------------------------------------------------- 1 | package cosign 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "strings" 8 | "time" 9 | 10 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 11 | "github.com/ChristofferNissen/helmper/pkg/helm" 12 | "github.com/ChristofferNissen/helmper/pkg/registry" 13 | "github.com/ChristofferNissen/helmper/pkg/report" 14 | "github.com/ChristofferNissen/helmper/pkg/util/bar" 15 | "github.com/ChristofferNissen/helmper/pkg/util/counter" 16 | "github.com/ChristofferNissen/helmper/pkg/util/terminal" 17 | "github.com/google/go-containerregistry/pkg/authn" 18 | "github.com/google/go-containerregistry/pkg/v1/remote" 19 | "github.com/jedib0t/go-pretty/v6/table" 20 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" 21 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/verify" 22 | "helm.sh/helm/v3/pkg/chartutil" 23 | ) 24 | 25 | type VerifyChartOption struct { 26 | Data map[*registry.Registry]map[*helm.Chart]bool 27 | VerifyExisting bool 28 | 29 | KeyRef string 30 | AllowInsecure bool 31 | AllowHTTPRegistry bool 32 | 33 | Report *report.Table 34 | } 35 | 36 | // VerifyOption wraps the cosign CLIs native code 37 | func (vo *VerifyChartOption) Run(ctx context.Context) (map[*registry.Registry]map[*helm.Chart]bool, error) { 38 | 39 | if vo.Report == nil { 40 | vo.Report = report.NewTable("Signature Overview For Charts") 41 | } 42 | 43 | var sc counter.SafeCounter = counter.NewSafeCounter() 44 | 45 | header := table.Row{"#", "Helm Chart", "Chart Version"} 46 | 47 | size := func() int { 48 | size := 0 49 | for _, m := range vo.Data { 50 | for _, b := range m { 51 | if b || vo.VerifyExisting { 52 | size++ 53 | } 54 | } 55 | } 56 | return size 57 | }() 58 | 59 | // Return early: no images to sign, or no registries to upload signature to 60 | if !(size > 0) { 61 | slog.Debug("No charts or registries specified. Skipping verifying charts...") 62 | return make(map[*registry.Registry]map[*helm.Chart]bool), nil 63 | } 64 | 65 | bar := bar.New("Verifying signatures...\r", size) 66 | 67 | o := &options.VerifyOptions{ 68 | Key: vo.KeyRef, 69 | CheckClaims: true, 70 | Output: "json", 71 | CommonVerifyOptions: options.CommonVerifyOptions{ 72 | IgnoreTlog: true, 73 | PrivateInfrastructure: true, 74 | ExperimentalOCI11: true, 75 | }, 76 | Registry: options.RegistryOptions{ 77 | AllowInsecure: vo.AllowInsecure, 78 | AllowHTTPRegistry: vo.AllowHTTPRegistry, 79 | 80 | RegistryClientOpts: []remote.Option{ 81 | remote.WithAuthFromKeychain(authn.DefaultKeychain), 82 | remote.WithRetryBackoff(remote.Backoff{ 83 | Duration: 1 * time.Second, 84 | Jitter: 1.0, 85 | Factor: 2.0, 86 | Steps: 5, 87 | Cap: 1 * time.Minute, 88 | }), 89 | }, 90 | }, 91 | } 92 | 93 | annotations, err := o.AnnotationsMap() 94 | if err != nil { 95 | return make(map[*registry.Registry]map[*helm.Chart]bool), err 96 | } 97 | 98 | hashAlgorithm, err := o.SignatureDigest.HashAlgorithm() 99 | if err != nil { 100 | return make(map[*registry.Registry]map[*helm.Chart]bool), err 101 | } 102 | 103 | v := &verify.VerifyCommand{ 104 | RegistryOptions: o.Registry, 105 | CertVerifyOptions: o.CertVerify, 106 | CheckClaims: o.CheckClaims, 107 | KeyRef: o.Key, 108 | CertRef: o.CertVerify.Cert, 109 | CertChain: o.CertVerify.CertChain, 110 | CAIntermediates: o.CertVerify.CAIntermediates, 111 | CARoots: o.CertVerify.CARoots, 112 | CertGithubWorkflowTrigger: o.CertVerify.CertGithubWorkflowTrigger, 113 | CertGithubWorkflowSha: o.CertVerify.CertGithubWorkflowSha, 114 | CertGithubWorkflowName: o.CertVerify.CertGithubWorkflowName, 115 | CertGithubWorkflowRepository: o.CertVerify.CertGithubWorkflowRepository, 116 | CertGithubWorkflowRef: o.CertVerify.CertGithubWorkflowRef, 117 | IgnoreSCT: o.CertVerify.IgnoreSCT, 118 | SCTRef: o.CertVerify.SCT, 119 | Sk: o.SecurityKey.Use, 120 | Slot: o.SecurityKey.Slot, 121 | Output: o.Output, 122 | RekorURL: o.Rekor.URL, 123 | Attachment: o.Attachment, 124 | Annotations: annotations, 125 | HashAlgorithm: hashAlgorithm, 126 | SignatureRef: o.SignatureRef, 127 | PayloadRef: o.PayloadRef, 128 | LocalImage: o.LocalImage, 129 | Offline: o.CommonVerifyOptions.Offline, 130 | TSACertChainPath: o.CommonVerifyOptions.TSACertChainPath, 131 | IgnoreTlog: o.CommonVerifyOptions.IgnoreTlog, 132 | MaxWorkers: o.CommonVerifyOptions.MaxWorkers, 133 | ExperimentalOCI11: o.CommonVerifyOptions.ExperimentalOCI11, 134 | } 135 | 136 | keys := make([]string, 0) 137 | rows := make(map[string]*table.Row) 138 | 139 | m := make(map[*registry.Registry]map[*helm.Chart]bool, 0) 140 | for r, elem := range vo.Data { 141 | if elem == nil { 142 | elem = make(map[*helm.Chart]bool, 0) 143 | } 144 | 145 | // extend table for each registry 146 | rn := r.GetName() 147 | header = append(header, rn) 148 | 149 | for c, b := range elem { 150 | 151 | // Check for existing row for Chart Name 152 | row := rows[c.Name] 153 | if row == nil { 154 | row = to.Ptr(table.Row{sc.Value("index_sign_charts"), fmt.Sprintf("charts/%s", c.Name), c.Version}) 155 | rows[c.Name] = row 156 | keys = append(keys, c.Name) 157 | } 158 | 159 | if b || vo.VerifyExisting { 160 | name := fmt.Sprintf("%s/%s", chartutil.ChartsDir, c.Name) 161 | d, err := r.Fetch(ctx, name, c.Version) 162 | if err != nil { 163 | return make(map[*registry.Registry]map[*helm.Chart]bool), err 164 | } 165 | 166 | out, err := terminal.CaptureOutput(func() error { 167 | url, _ := strings.CutPrefix(r.URL, "oci://") 168 | url = strings.Replace(url, "0.0.0.0", "localhost", 1) 169 | s := fmt.Sprintf("%s/%s/%s@%s", url, chartutil.ChartsDir, c.Name, d.Digest) 170 | err := v.Exec(ctx, []string{s}) 171 | return err 172 | }) 173 | slog.Debug(out) 174 | 175 | if err != nil { 176 | switch { 177 | case isNoCertificateFoundOnSignatureErr(err): 178 | fallthrough 179 | case isNoMatchingSignatureErr(err): 180 | fallthrough 181 | case isImageWithoutSignatureErr(err): 182 | elem[c] = true 183 | default: 184 | return make(map[*registry.Registry]map[*helm.Chart]bool), err 185 | } 186 | } else { 187 | elem[c] = false 188 | } 189 | 190 | *row = append(*row, terminal.StatusEmoji(!elem[c])) 191 | sc.Inc("index_sign_charts") 192 | _ = bar.Add(1) 193 | } 194 | 195 | } 196 | 197 | if len(elem) > 0 { 198 | m[r] = elem 199 | } 200 | } 201 | 202 | // Output table 203 | for _, k := range keys { 204 | valP := rows[k] 205 | if valP != nil { 206 | vo.Report.AddRow(*valP) 207 | } 208 | } 209 | vo.Report.AddHeader(header) 210 | 211 | _ = bar.Finish() 212 | 213 | return m, nil 214 | } 215 | -------------------------------------------------------------------------------- /pkg/exportArtifacts/main.go: -------------------------------------------------------------------------------- 1 | package exportArtifacts 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/ChristofferNissen/helmper/pkg/helm" 10 | "github.com/spf13/afero" 11 | ) 12 | 13 | type ExportOption struct { 14 | Fs afero.Fs 15 | Image helm.RegistryImageStatus 16 | Chart helm.RegistryChartStatus 17 | } 18 | 19 | type ChartArtifact struct { 20 | ChartOverview string `json:"chart_overview"` 21 | ChartName string `json:"chart_name"` 22 | Repository string `json:"repository"` 23 | ChartVersion string `json:"chart_version"` 24 | ChartPath string `json:"chart_artifact_path"` 25 | } 26 | 27 | type ImageArtifact struct { 28 | ImageOverview string `json:"image_overview"` 29 | ImageName string `json:"image_name"` 30 | ImageTag string `json:"image_tag"` 31 | } 32 | 33 | func (eo *ExportOption) Run(ctx context.Context, folder string) ([]ImageArtifact, []ChartArtifact, error) { 34 | // Collect image data 35 | imageArtifacts := []ImageArtifact{} 36 | for r, i := range eo.Image { 37 | for img := range i { 38 | overview := fmt.Sprintf("Registry: %s, Image: %s, Tag: %s", 39 | r.GetName(), 40 | img.String(), img.Tag) 41 | ia := ImageArtifact{ 42 | ImageOverview: overview, 43 | ImageName: img.String(), 44 | ImageTag: img.Tag, 45 | } 46 | imageArtifacts = append(imageArtifacts, ia) 47 | } 48 | } 49 | 50 | // Collect chart data 51 | chartArtifacts := []ChartArtifact{} 52 | for r, c := range eo.Chart { 53 | for chart := range c { 54 | overview := fmt.Sprintf("Registry: %s, Chart: %s, Version: %s, ChartPath: %s", 55 | r.Name, chart.Name, chart.Version, fmt.Sprintf("charts/%s", chart.Name)) 56 | 57 | ca := ChartArtifact{ 58 | ChartOverview: overview, 59 | ChartName: chart.Name, 60 | ChartVersion: chart.Version, 61 | ChartPath: fmt.Sprintf("charts/%s", chart.Name), 62 | } 63 | chartArtifacts = append(chartArtifacts, ca) 64 | } 65 | } 66 | 67 | exportData := struct { 68 | Images []ImageArtifact `json:"images"` 69 | Charts []ChartArtifact `json:"charts"` 70 | }{ 71 | Images: imageArtifacts, 72 | Charts: chartArtifacts, 73 | } 74 | 75 | jsonData, err := json.MarshalIndent(exportData, "", " ") 76 | if err != nil { 77 | slog.Error("Failed to export data to JSON", slog.String("error", err.Error())) 78 | return nil, nil, fmt.Errorf("failed to export data to JSON: %w", err) 79 | } 80 | 81 | destPath := "artifacts.json" 82 | if folder != "" { 83 | err = eo.Fs.MkdirAll(folder, 0755) 84 | if err != nil { 85 | slog.Error("Failed to create directory", slog.String("folder", folder), slog.String("error", err.Error())) 86 | return nil, nil, fmt.Errorf("failed to save file in the specified location %s: %w", folder, err) 87 | } 88 | destPath = fmt.Sprintf("%s/%s", folder, destPath) 89 | } else { 90 | destPath = "./" + destPath 91 | slog.Info("No folder specified, saving in the root directory") 92 | } 93 | 94 | err = afero.WriteFile(eo.Fs, destPath, jsonData, 0644) 95 | if err != nil { 96 | slog.Error("Failed to write artifacts to", destPath, slog.String("error", err.Error())) 97 | return nil, nil, fmt.Errorf("failed to write artifacts to %s: %w", destPath, err) 98 | } 99 | 100 | slog.Info("Exported artifacts", slog.String("path", destPath)) 101 | return imageArtifacts, chartArtifacts, nil 102 | } -------------------------------------------------------------------------------- /pkg/exportArtifacts/main_test.go: -------------------------------------------------------------------------------- 1 | package exportArtifacts 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/ChristofferNissen/helmper/pkg/helm" 9 | "github.com/ChristofferNissen/helmper/pkg/image" 10 | "github.com/ChristofferNissen/helmper/pkg/registry" 11 | "github.com/spf13/afero" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestExportOptionRun(t *testing.T) { 16 | // Arrange 17 | mockRegistry := ®istry.Registry{Name: "azure.registry.io"} 18 | mockImage := &image.Image{Repository: "argocd", Tag: "v2.0.5"} 19 | mockChart := &helm.Chart{Name: "prometheus", Version: "1.0.0"} 20 | 21 | mockData := helm.RegistryImageStatus{ 22 | mockRegistry: { 23 | mockImage: true, 24 | }, 25 | } 26 | 27 | mockData2 := helm.RegistryChartStatus { 28 | mockRegistry: { 29 | mockChart: true, 30 | }, 31 | } 32 | 33 | mockFs := afero.NewMemMapFs() 34 | eo := &ExportOption{ 35 | Fs: mockFs, 36 | Image: mockData, 37 | Chart: mockData2, 38 | } 39 | 40 | // Act 41 | imgOverview, chartOverview, err := eo.Run(context.Background(), "") 42 | 43 | // Assert 44 | 45 | assert.NoError(t, err) 46 | 47 | content, err := afero.ReadFile(mockFs, "artifacts.json") 48 | assert.NoError(t, err) 49 | 50 | 51 | var artifact struct { 52 | Images []ImageArtifact `json:"images"` 53 | Charts []ChartArtifact `json:"charts"` 54 | } 55 | err = json.Unmarshal(content, &artifact) 56 | assert.NoError(t, err) 57 | 58 | assert.EqualValues(t, imgOverview, artifact.Images) 59 | assert.EqualValues(t, chartOverview, artifact.Charts) 60 | } 61 | 62 | func TestExportOptionRun_NoData(t *testing.T) { 63 | // Arrange 64 | mockFs := afero.NewMemMapFs() 65 | 66 | eo := &ExportOption{ 67 | Fs: mockFs, 68 | Image: helm.RegistryImageStatus{}, 69 | Chart: helm.RegistryChartStatus{}, 70 | } 71 | 72 | // Act 73 | imgOverview, chartOverview, err := eo.Run(context.Background(), "") 74 | 75 | // Assert 76 | assert.NoError(t, err) 77 | assert.Empty(t, imgOverview, "expected no image artifacts") 78 | assert.Empty(t, chartOverview, "expected no chart artifacts") 79 | } -------------------------------------------------------------------------------- /pkg/flow/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package Flow defines program flows (business logic) 3 | 4 | */ 5 | 6 | package flow 7 | -------------------------------------------------------------------------------- /pkg/flow/spsOption.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/ChristofferNissen/helmper/pkg/copa" 14 | "github.com/ChristofferNissen/helmper/pkg/image" 15 | "github.com/ChristofferNissen/helmper/pkg/registry" 16 | "github.com/ChristofferNissen/helmper/pkg/trivy" 17 | myBar "github.com/ChristofferNissen/helmper/pkg/util/bar" 18 | ) 19 | 20 | type SpsOption struct { 21 | Data map[*registry.Registry]map[*image.Image]bool 22 | All bool 23 | Architecture *string 24 | ReportsFolder string 25 | ReportsClean bool 26 | TarsFolder string 27 | TarsClean bool 28 | ScanOption trivy.ScanOption 29 | PatchOption copa.PatchOption 30 | } 31 | 32 | func (o SpsOption) Run(ctx context.Context) error { 33 | // Count of images to import across registries 34 | lenImages := func() int { 35 | c := 0 36 | seen := make([]image.Image, 0) 37 | for _, m := range o.Data { 38 | for i, b := range m { 39 | if b { 40 | if i.In(seen) { 41 | continue 42 | } 43 | seen = append(seen, *i) 44 | c++ 45 | } 46 | } 47 | } 48 | return c 49 | }() 50 | 51 | if !(lenImages > 0) { 52 | return nil 53 | } 54 | 55 | // Trivy scan 56 | bar := myBar.New("Scanning images before patching...\r", lenImages) 57 | prescan := func() (map[*registry.Registry]map[*image.Image]bool, map[*registry.Registry]map[*image.Image]bool, error) { 58 | imgs := make([]*image.Image, 0) 59 | for _, m := range o.Data { 60 | for i, b := range m { 61 | if b { 62 | if i.InP(imgs) { 63 | continue 64 | } 65 | imgs = append(imgs, i) 66 | } 67 | } 68 | } 69 | 70 | patch := make([]*image.Image, 0) 71 | push := make([]*image.Image, 0) 72 | for _, i := range imgs { 73 | if i.Patch != nil { 74 | if !*i.Patch { 75 | ref := i.String() 76 | slog.Debug("image should not be patched", slog.String("image", ref)) 77 | push = append(push, i) 78 | continue 79 | } 80 | } 81 | 82 | ref := i.String() 83 | r, err := o.ScanOption.Scan(ref) 84 | if err != nil { 85 | return nil, nil, err 86 | } 87 | 88 | switch copa.SupportedOS(r.Metadata.OS) { 89 | case true: 90 | // filter images with no os-pkgs as copa has nothing to do 91 | switch trivy.ContainsOsPkgs(r.Results) { 92 | case true: 93 | slog.Debug("Image does contain os-pkgs vulnerabilities", 94 | slog.String("image", ref)) 95 | patch = append(patch, i) 96 | case false: 97 | slog.Warn("Image does not contain os-pkgs. The image will not be patched.", 98 | slog.String("image", ref), 99 | ) 100 | push = append(push, i) 101 | } 102 | case false: 103 | slog.Warn("Image contains an unsupported OS. The image will not be patched.", 104 | slog.String("image", ref), 105 | ) 106 | push = append(push, i) 107 | } 108 | 109 | // Write report to filesystem 110 | name, _ := i.ImageName() 111 | fileName := fmt.Sprintf("%s:%s.json", name, i.Tag) 112 | fileName = filepath.Join(o.ReportsFolder, "prescan-"+strings.ReplaceAll(fileName, "/", "-")) 113 | b, err := json.MarshalIndent(r, "", " ") 114 | if err != nil { 115 | return nil, nil, err 116 | } 117 | if err := os.WriteFile(fileName, b, os.ModePerm); err != nil { 118 | return nil, nil, err 119 | } 120 | } 121 | 122 | // filter images 123 | patchM := make(map[*registry.Registry]map[*image.Image]bool, 0) 124 | pushM := make(map[*registry.Registry]map[*image.Image]bool, 0) 125 | for r, elem := range o.Data { 126 | patchRegistry := patchM[r] 127 | if patchRegistry == nil { 128 | patchRegistry = make(map[*image.Image]bool, 0) 129 | patchM[r] = patchRegistry 130 | } 131 | pushRegistry := pushM[r] 132 | if pushRegistry == nil { 133 | pushRegistry = make(map[*image.Image]bool, 0) 134 | pushM[r] = pushRegistry 135 | } 136 | 137 | for i, b := range elem { 138 | if b { 139 | switch { 140 | case i.InP(patch): 141 | patchRegistry[i] = true 142 | case i.InP(push): 143 | pushRegistry[i] = true 144 | } 145 | 146 | _ = bar.Add(1) 147 | } 148 | } 149 | 150 | patchM[r] = patchRegistry 151 | pushM[r] = pushRegistry 152 | } 153 | 154 | return patchM, pushM, nil 155 | } 156 | patch, push, err := prescan() 157 | if err != nil { 158 | return err 159 | } 160 | _ = bar.Finish() 161 | 162 | // Determine fully qualified output path for images 163 | reportFilePaths := make(map[*image.Image]string) 164 | reportPostFilePaths := make(map[*image.Image]string) 165 | outFilePaths := make(map[*image.Image]string) 166 | for _, elem := range o.Data { 167 | for i, b := range elem { 168 | if b { 169 | name, _ := i.ImageName() 170 | fileName := fmt.Sprintf("prescan-%s:%s.json", name, i.Tag) 171 | reportFilePaths[i] = filepath.Join( 172 | o.ReportsFolder, 173 | strings.ReplaceAll(fileName, "/", "-"), 174 | ) 175 | fileName = fmt.Sprintf("postscan-%s:%s.json", name, i.Tag) 176 | reportPostFilePaths[i] = filepath.Join( 177 | o.ReportsFolder, 178 | strings.ReplaceAll(fileName, "/", "-"), 179 | ) 180 | out := fmt.Sprintf("%s:%s.tar", name, i.Tag) 181 | outFilePaths[i] = filepath.Join( 182 | o.TarsFolder, 183 | strings.ReplaceAll(out, "/", "-"), 184 | ) 185 | } 186 | } 187 | } 188 | // Clean up files 189 | defer func() { 190 | if o.ReportsClean { 191 | for _, v := range reportFilePaths { 192 | _ = os.RemoveAll(v) 193 | } 194 | for _, v := range reportPostFilePaths { 195 | _ = os.RemoveAll(v) 196 | } 197 | } 198 | if o.TarsClean { 199 | for _, v := range outFilePaths { 200 | _ = os.RemoveAll(v) 201 | } 202 | } 203 | }() 204 | 205 | // Import images without os-pkgs vulnerabilities 206 | io := registry.ImportOption{ 207 | Data: push, 208 | All: o.All, 209 | Architecture: o.Architecture, 210 | } 211 | err = io.Run(context.WithoutCancel(ctx)) 212 | if err != nil { 213 | return err 214 | } 215 | 216 | // Patch image and save to tar 217 | o.PatchOption.Data = patch 218 | err = o.PatchOption.Run(context.WithoutCancel(ctx), reportFilePaths, outFilePaths) 219 | if err != nil { 220 | return err 221 | } 222 | 223 | bar = myBar.New("Scanning images after patching...\r", lenImages) 224 | err = func(out string, prefix string) error { 225 | for _, m := range o.Data { 226 | for i, b := range m { 227 | if b { 228 | ref := i.String() 229 | 230 | slog.Default().With(slog.String("image", ref)) 231 | 232 | r, err := o.ScanOption.Scan(ref) 233 | if err != nil { 234 | return err 235 | } 236 | 237 | // Write report to filesystem 238 | name, _ := i.ImageName() 239 | fileName := fmt.Sprintf("%s:%s.json", name, i.Tag) 240 | fileName = filepath.Join(out, prefix+strings.ReplaceAll(fileName, "/", "-")) 241 | b, err := json.MarshalIndent(r, "", " ") 242 | if err != nil { 243 | return err 244 | } 245 | if err := os.WriteFile(fileName, b, os.ModePerm); err != nil { 246 | return err 247 | } 248 | 249 | _ = bar.Add(1) 250 | } 251 | } 252 | } 253 | return nil 254 | }(o.ReportsFolder, "postscan-") 255 | if err != nil { 256 | return err 257 | } 258 | _ = bar.Finish() 259 | 260 | return nil 261 | } 262 | -------------------------------------------------------------------------------- /pkg/helm/OCIRegistry.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | helm_registry "helm.sh/helm/v3/pkg/registry" 8 | "oras.land/oras-go/v2/registry/remote" 9 | "oras.land/oras-go/v2/registry/remote/auth" 10 | "oras.land/oras-go/v2/registry/remote/credentials" 11 | "oras.land/oras-go/v2/registry/remote/retry" 12 | ) 13 | 14 | type OCIRegistryClient struct { 15 | client RegistryClient 16 | PlainHTTP bool 17 | } 18 | 19 | func NewOCIRegistryClient(client RegistryClient, plainHTTP bool) *OCIRegistryClient { 20 | return &OCIRegistryClient{ 21 | client: client, 22 | PlainHTTP: plainHTTP, 23 | } 24 | } 25 | 26 | func (c *OCIRegistryClient) Pull(ref string, opts ...helm_registry.PullOption) (*helm_registry.PullResult, error) { 27 | return c.client.Pull(ref, opts...) 28 | } 29 | 30 | func (c *OCIRegistryClient) Push(chart []byte, destination string, opts ...helm_registry.PushOption) (*helm_registry.PushResult, error) { 31 | return c.client.Push(chart, destination, opts...) 32 | } 33 | 34 | func (c *OCIRegistryClient) Tags(ref string) ([]string, error) { 35 | ref = strings.TrimPrefix(strings.TrimSuffix(ref, "/"), "oci://") 36 | repo, err := remote.NewRepository(ref) 37 | if err != nil { 38 | return []string{}, err 39 | } 40 | repo.PlainHTTP = c.PlainHTTP 41 | 42 | storeOpts := credentials.StoreOptions{} 43 | credStore, err := credentials.NewStoreFromDocker(storeOpts) 44 | if err != nil { 45 | return []string{}, err 46 | } 47 | repo.Client = &auth.Client{ 48 | Client: retry.DefaultClient, 49 | Cache: auth.NewCache(), 50 | Credential: credentials.Credential(credStore), // Use the credentials store 51 | } 52 | 53 | vs := []string{} 54 | err = repo.Tags(context.TODO(), "", func(tags []string) error { 55 | vs = append(vs, tags...) 56 | return nil 57 | }) 58 | if err != nil { 59 | return []string{}, err 60 | } 61 | 62 | return vs, nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/helm/chartCollection.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "log" 5 | "log/slog" 6 | "strings" 7 | 8 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 9 | "github.com/ChristofferNissen/helmper/pkg/util/terminal" 10 | "github.com/jinzhu/copier" 11 | "helm.sh/helm/v3/pkg/cli" 12 | ) 13 | 14 | func (collection ChartCollection) pull(settings *cli.EnvSettings) error { 15 | for _, chart := range collection.Charts { 16 | if _, err := chart.Pull(settings); err != nil { 17 | return err 18 | } 19 | } 20 | return nil 21 | } 22 | 23 | func (collection ChartCollection) addToHelmRepositoryConfig(settings *cli.EnvSettings) error { 24 | for _, c := range collection.Charts { 25 | if strings.HasPrefix(c.Repo.URL, "oci://") { 26 | continue 27 | } 28 | _, err := c.addToHelmRepositoryFile(settings) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | } 34 | return nil 35 | } 36 | 37 | // configures helm and pulls charts to local fs 38 | func (collection ChartCollection) SetupHelm(settings *cli.EnvSettings, setters ...Option) (*ChartCollection, error) { 39 | 40 | // Default Options 41 | args := &Options{ 42 | Verbose: false, 43 | Update: false, 44 | } 45 | 46 | for _, setter := range setters { 47 | setter(args) 48 | } 49 | 50 | // Add Helm Repos 51 | err := collection.addToHelmRepositoryConfig(settings) 52 | if err != nil { 53 | return nil, err 54 | } 55 | if args.Verbose { 56 | log.Printf("Added Helm repositories to config '%s' %s\n", settings.RepositoryConfig, terminal.GetCheckMarkEmoji()) 57 | } 58 | 59 | // Update Helm Repos 60 | output, err := updateRepositories(settings, args.Verbose, args.Update) 61 | if err != nil { 62 | return nil, err 63 | } 64 | // Log results 65 | if args.Verbose { 66 | log.Printf("Updated all Helm repositories %s\n%s", terminal.GetCheckMarkEmoji(), output) 67 | } else { 68 | log.Printf("Updated all Helm repositories %s\n", terminal.GetCheckMarkEmoji()) 69 | } 70 | 71 | // Expand collection if semantic version range 72 | res := []*Chart{} 73 | for _, c := range collection.Charts { 74 | vs, err := c.ResolveVersions(settings) 75 | if err != nil { 76 | // resolve Glob version 77 | v, err := c.ResolveVersion(settings) 78 | if err != nil { 79 | slog.Info("version is not semver. skipping this version", slog.String("name", c.Name), slog.String("version", c.Version)) 80 | continue 81 | } 82 | c.Version = v 83 | res = append(res, c) 84 | } 85 | 86 | for _, v := range vs { 87 | cv := &Chart{} 88 | err := copier.Copy(&cv, &c) 89 | if err != nil { 90 | return nil, err 91 | } 92 | cv.Version = v 93 | res = append(res, cv) 94 | } 95 | } 96 | collection.Charts = res 97 | 98 | // Pull Helm Charts 99 | err = collection.pull(settings) 100 | if err != nil { 101 | return nil, err 102 | } 103 | if args.Verbose { 104 | log.Println("Pulled Helm Charts") 105 | } 106 | 107 | return to.Ptr(collection), nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/helm/chartImportOption.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "log/slog" 8 | "os" 9 | "sort" 10 | 11 | "github.com/jedib0t/go-pretty/v6/table" 12 | "golang.org/x/sync/errgroup" 13 | "helm.sh/helm/v3/pkg/cli" 14 | 15 | "github.com/ChristofferNissen/helmper/pkg/image" 16 | "github.com/ChristofferNissen/helmper/pkg/registry" 17 | "github.com/ChristofferNissen/helmper/pkg/report" 18 | "github.com/ChristofferNissen/helmper/pkg/util/bar" 19 | "github.com/ChristofferNissen/helmper/pkg/util/counter" 20 | "github.com/ChristofferNissen/helmper/pkg/util/terminal" 21 | ) 22 | 23 | type IdentifyImportOption struct { 24 | Registries []*registry.Registry 25 | ChartImageValuesMap ChartData 26 | 27 | All bool 28 | ImportEnabled bool 29 | 30 | ChartsOverview *report.Table 31 | ImagesOverview *report.Table 32 | } 33 | 34 | // Converts data structure to pipeline parameters 35 | func (io *IdentifyImportOption) Run(_ context.Context) (RegistryChartStatus, RegistryImageStatus, error) { 36 | if io.ChartsOverview == nil { 37 | io.ChartsOverview = report.NewTable("Registry Overview For Charts") 38 | } 39 | if io.ImagesOverview == nil { 40 | io.ImagesOverview = report.NewTable("Registry Overview For Images") 41 | } 42 | 43 | var sc counter.SafeCounter = counter.NewSafeCounter() 44 | 45 | c_header := table.Row{"#", "Helm Chart", "Chart Version"} 46 | c_footer := table.Row{"", "", ""} 47 | 48 | i_header := table.Row{"#", "Helm Chart", "Chart Version", "Image"} 49 | i_footer := table.Row{"", "", "", ""} 50 | 51 | // registry -> Charts -> bool 52 | m1 := make(RegistryChartStatus, 0) 53 | // registry -> Images -> bool 54 | m2 := make(RegistryImageStatus, 0) 55 | 56 | for c := range io.ChartImageValuesMap { 57 | if c.Name == "images" { 58 | continue 59 | } 60 | 61 | // Charts 62 | n := fmt.Sprintf("charts/%s", c.Name) 63 | v := c.Version 64 | 65 | row := table.Row{sc.Value("index_import_charts"), fmt.Sprintf("charts/%s", c.Name), c.Version} 66 | 67 | for _, r := range io.Registries { 68 | elem := m1[r] 69 | if elem == nil { 70 | // init map 71 | elem = make(map[*Chart]bool, 0) 72 | m1[r] = elem 73 | } 74 | 75 | existsInRegistry := registry.Exists(context.TODO(), n, v, []*registry.Registry{r})[r.URL] 76 | b := io.All || !existsInRegistry 77 | elem[c] = b 78 | if b { 79 | sc.Inc(r.URL + "charts") 80 | } 81 | row = append(row, terminal.StatusEmoji(existsInRegistry), terminal.StatusEmoji(b)) 82 | } 83 | io.ChartsOverview.AddRow(row) 84 | 85 | sc.Inc("index_import_charts") 86 | } 87 | 88 | var seenImages []image.Image = make([]image.Image, 0) 89 | for c, imageMap := range io.ChartImageValuesMap { 90 | // Images 91 | for i := range imageMap { 92 | if i.In(seenImages) { 93 | ref := i.String() 94 | log.Printf("Already parsed '%s', skipping...\n", ref) 95 | continue 96 | } 97 | // make sure we don't parse again 98 | seenImages = append(seenImages, *i) 99 | 100 | // decide if image should be imported 101 | name, err := i.ImageName() 102 | if err != nil { 103 | return nil, nil, err 104 | } 105 | 106 | // add row to overview table 107 | ref := i.String() 108 | row := table.Row{sc.Value("index_import"), c.Name, c.Version, ref} 109 | 110 | for _, r := range io.Registries { 111 | if r.PrefixSource { 112 | old := name 113 | name, _ = image.UpdateNameWithPrefixSource(i) 114 | slog.Info("registry has PrefixSource enabled", slog.String("old", old), slog.String("new", name)) 115 | } 116 | 117 | // check if image exists in registry 118 | registryImageStatusMap := registry.Exists(context.TODO(), name, i.Tag, []*registry.Registry{r}) 119 | // loop over registries 120 | imageExistsInRegistry := registryImageStatusMap[r.URL] 121 | 122 | row = append(row, terminal.StatusEmoji(imageExistsInRegistry)) 123 | 124 | elem := m2[r] 125 | if elem == nil { 126 | // init map 127 | elem = make(map[*image.Image]bool, 0) 128 | m2[r] = elem 129 | } 130 | b := io.All || !imageExistsInRegistry 131 | elem[i] = b 132 | 133 | if b { 134 | sc.Inc(r.URL) 135 | } 136 | row = append(row, terminal.StatusEmoji(b)) 137 | } 138 | 139 | sc.Inc("index_import") 140 | io.ImagesOverview.AddRow(row) 141 | } 142 | } 143 | 144 | // Table 145 | for _, r := range io.Registries { 146 | // dynamic number of registries in table 147 | rn := r.GetName() 148 | c_header = append(c_header, rn) 149 | c_footer = append(c_footer, "") 150 | i_header = append(i_header, rn) 151 | i_footer = append(i_footer, "") 152 | 153 | if io.ImportEnabled { 154 | // second static part of header 155 | c_header = append(c_header, "import") 156 | c_footer = append(c_footer, sc.Value(r.URL+"charts")) 157 | i_header = append(i_header, "import") 158 | i_footer = append(i_footer, sc.Value(r.URL)) 159 | } 160 | } 161 | 162 | io.ChartsOverview.AddHeader(c_header) 163 | io.ChartsOverview.AddFooter(c_footer) 164 | io.ImagesOverview.AddHeader(i_header) 165 | io.ImagesOverview.AddFooter(i_footer) 166 | 167 | return m1, m2, nil 168 | } 169 | 170 | type ChartImportOption struct { 171 | Data RegistryChartStatus 172 | All bool 173 | ModifyRegistry bool 174 | 175 | Settings *cli.EnvSettings 176 | } 177 | 178 | func (opt ChartImportOption) Run(ctx context.Context, setters ...Option) error { 179 | // Default Options 180 | args := &Options{ 181 | Verbose: false, 182 | Update: false, 183 | K8SVersion: "1.31.1", 184 | } 185 | 186 | for _, setter := range setters { 187 | setter(args) 188 | } 189 | 190 | if opt.Settings == nil { 191 | opt.Settings = cli.New() 192 | } 193 | 194 | size := func() int { 195 | size := 0 196 | for _, m := range opt.Data { 197 | for _, b := range m { 198 | if b { 199 | size++ 200 | } 201 | } 202 | } 203 | return size 204 | }() 205 | 206 | if size <= 0 { 207 | return nil 208 | } 209 | 210 | bar := bar.New("Pushing charts...\r", size) 211 | 212 | eg, egCtx := errgroup.WithContext(ctx) 213 | eg.Go(func() error { 214 | eg, egCtx := errgroup.WithContext(egCtx) 215 | 216 | for r, m := range opt.Data { 217 | charts := []*Chart{} 218 | 219 | eg.Go(func() error { 220 | for c, b := range m { 221 | if b { 222 | chartRef, err := c.ChartRef(opt.Settings) 223 | if err != nil { 224 | return err 225 | } 226 | 227 | c.DepsCount = len(chartRef.Metadata.Dependencies) 228 | charts = append(charts, c) 229 | } 230 | } 231 | 232 | // Sort charts according to least dependencies 233 | sort.Slice(charts, func(i, j int) bool { 234 | return charts[i].DepsCount < charts[j].DepsCount 235 | }) 236 | 237 | for _, c := range charts { 238 | 239 | // scope 240 | c := c 241 | 242 | eg.Go(func() error { 243 | slog.With(slog.String("chart", c.Name)) 244 | 245 | if c.Name == "images" { 246 | return nil 247 | } 248 | 249 | if !opt.All { 250 | _, err := r.Exist(egCtx, "charts/"+c.Name, c.Version) 251 | if err == nil { 252 | slog.Info("Chart already present in registry. Skipping import", slog.String("chart", "charts/"+c.Name), slog.String("registry", r.URL), slog.String("version", c.Version)) 253 | return nil 254 | } 255 | slog.Debug(err.Error()) 256 | } 257 | 258 | if opt.ModifyRegistry { 259 | res, err := c.PushAndModify(opt.Settings, r.URL, r.Insecure, r.PlainHTTP, r.PrefixSource) 260 | if err != nil { 261 | return fmt.Errorf("helm: error pushing and modifying chart %s to registry %s :: %w", c.Name, r.URL, err) 262 | } 263 | slog.Debug(res) 264 | defer os.RemoveAll(res) 265 | _ = bar.Add(1) 266 | return nil 267 | } 268 | 269 | client, err := NewRegistryClient(r.PlainHTTP, false) 270 | if err != nil { 271 | return fmt.Errorf("helm: error creating registry client :: %w", err) 272 | } 273 | c.RegistryClient = client 274 | res, err := c.Push(opt.Settings, r.URL, r.Insecure, r.PlainHTTP) 275 | if err != nil { 276 | return fmt.Errorf("helm: error pushing chart %s to registry %s :: %w", c.Name, r.URL, err) 277 | } 278 | slog.Debug(res) 279 | 280 | _ = bar.Add(1) 281 | 282 | return nil 283 | }) 284 | } 285 | 286 | return nil 287 | }) 288 | } 289 | 290 | err := eg.Wait() 291 | if err != nil { 292 | return err 293 | } 294 | 295 | return nil 296 | }) 297 | err := eg.Wait() 298 | if err != nil { 299 | return err 300 | } 301 | 302 | return bar.Finish() 303 | } 304 | -------------------------------------------------------------------------------- /pkg/helm/chart_test.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 10 | "github.com/ChristofferNissen/helmper/pkg/util/file" 11 | "github.com/smallstep/assert" 12 | "github.com/stretchr/testify/mock" 13 | "helm.sh/helm/v3/pkg/cli" 14 | helm_registry "helm.sh/helm/v3/pkg/registry" 15 | "helm.sh/helm/v3/pkg/repo" 16 | ) 17 | 18 | // Define the mock registry client 19 | type MockRegistryClient struct { 20 | mock.Mock 21 | } 22 | 23 | func (m *MockRegistryClient) Pull(ref string, opts ...helm_registry.PullOption) (*helm_registry.PullResult, error) { 24 | args := m.Called(ref) 25 | return to.Ptr(helm_registry.PullResult{ 26 | Ref: ref, 27 | }), args.Error(1) 28 | } 29 | 30 | func (m *MockRegistryClient) Push(chart []byte, destination string, opts ...helm_registry.PushOption) (*helm_registry.PushResult, error) { 31 | args := m.Called(chart, destination) 32 | return to.Ptr(helm_registry.PushResult{ 33 | Ref: destination, 34 | }), args.Error(1) 35 | } 36 | 37 | func (m *MockRegistryClient) Tags(ref string) ([]string, error) { 38 | args := m.Called(ref) 39 | return args.Get(0).([]string), args.Error(1) 40 | } 41 | 42 | func createTempDir() (string, func(), error) { 43 | // Create a new temporary directory 44 | tempDir, err := os.MkdirTemp("", "tempdir_*") 45 | if err != nil { 46 | return "", nil, err 47 | } 48 | 49 | // Define the cleanup function 50 | cleanup := func() { 51 | err := os.RemoveAll(tempDir) 52 | if err != nil { 53 | fmt.Printf("Failed to remove temp dir: %v\n", err) 54 | } else { 55 | fmt.Printf("Temp dir %s removed.\n", tempDir) 56 | } 57 | } 58 | 59 | return tempDir, cleanup, nil 60 | } 61 | 62 | func testSettings() (*cli.EnvSettings, error) { 63 | // Create a temporary directory 64 | tempDir, cleanup, err := createTempDir() 65 | if err != nil { 66 | fmt.Printf("Error creating temp dir: %v\n", err) 67 | return nil, err 68 | } 69 | // Ensure cleanup is called to remove the temp directory 70 | defer cleanup() 71 | // Use the temp directory for your operations 72 | fmt.Printf("Temporary directory created: %s\n", tempDir) 73 | settings := cli.New() 74 | settings.RepositoryCache = tempDir 75 | f := repo.NewFile() 76 | repoFile := filepath.Join(tempDir, "repositories.yaml") 77 | f.WriteFile(repoFile, 0644) 78 | settings.RepositoryConfig = repoFile 79 | 80 | return settings, nil 81 | } 82 | 83 | func TestPull(t *testing.T) { 84 | cases := []struct { 85 | name string 86 | chart Chart 87 | expectErr bool 88 | expectExist bool 89 | }{ 90 | { 91 | name: "Valid OCI URL", 92 | chart: Chart{ 93 | Repo: repo.Entry{ 94 | URL: "oci://chartproxy.container-registry.com/charts.jetstack.io/cert-manager", 95 | }, 96 | Name: "cert-manager", 97 | Version: "1.0.0", 98 | }, 99 | expectErr: false, 100 | expectExist: true, 101 | }, 102 | { 103 | name: "Valid non-OCI URL", 104 | chart: Chart{ 105 | Repo: repo.Entry{ 106 | URL: "https://kubernetes.github.io/ingress-nginx", 107 | InsecureSkipTLSverify: false, 108 | Username: "", 109 | Password: "", 110 | }, 111 | Name: "ingress-nginx", 112 | Version: "4.11.3", 113 | }, 114 | expectErr: false, 115 | expectExist: true, 116 | }, 117 | { 118 | name: "Invalid URL", 119 | chart: Chart{ 120 | Repo: repo.Entry{ 121 | URL: "invalid://url", 122 | }, 123 | Name: "mychart", 124 | Version: "1.0.0", 125 | }, 126 | expectErr: true, 127 | expectExist: false, 128 | }, 129 | } 130 | 131 | for _, c := range cases { 132 | settings, _ := testSettings() 133 | 134 | t.Run(c.name, func(t *testing.T) { 135 | p, err := c.chart.Pull(settings) 136 | if (err != nil) != c.expectErr { 137 | t.Errorf("expected error: %v, got: %v", c.expectErr, err) 138 | } 139 | if p != "" && err != nil { 140 | t.Error("Path should be empty when err is returned") 141 | } 142 | 143 | b := file.FileExists(p) 144 | defer os.RemoveAll(p) 145 | if b != c.expectExist { 146 | t.Errorf("expected tarPath does not exist: %v, got: %v", c.expectExist, b) 147 | } 148 | }) 149 | } 150 | } 151 | 152 | func TestPush(t *testing.T) { 153 | // Create a mock registry client 154 | mockClient := new(MockRegistryClient) 155 | 156 | chart := Chart{ 157 | Name: "testchart", 158 | RegistryClient: mockClient, 159 | } 160 | 161 | chartFilePath := "/tmp/testchart.tgz" 162 | destination := "localhost:5000/testchart:0.1.0" 163 | 164 | // Create a dummy chart file for testing 165 | err := os.WriteFile(chartFilePath, []byte("test data"), 0644) 166 | assert.NoError(t, err) 167 | defer os.Remove(chartFilePath) 168 | 169 | // Set up the expectations for the mock 170 | mockClient.On("Push", mock.Anything, destination).Return("success", nil) 171 | 172 | // Test the push function 173 | err = chart.push(chartFilePath, destination) 174 | assert.NoError(t, err) 175 | 176 | // Assert that the expectations were met 177 | mockClient.AssertExpectations(t) 178 | } 179 | -------------------------------------------------------------------------------- /pkg/helm/chart_version.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | 8 | "github.com/blang/semver/v4" 9 | "golang.org/x/xerrors" 10 | "helm.sh/helm/v3/pkg/cli" 11 | "helm.sh/helm/v3/pkg/repo" 12 | ) 13 | 14 | func VersionsInRange(r semver.Range, c Chart) ([]string, error) { 15 | prefixV := strings.Contains(c.Version, "v") 16 | config := cli.New() 17 | indexPath := fmt.Sprintf("%s/%s-index.yaml", config.RepositoryCache, c.Repo.Name) 18 | index, err := c.IndexFileLoader.LoadIndexFile(indexPath) 19 | if err != nil { 20 | return nil, err 21 | } 22 | index.SortEntries() 23 | versions := index.Entries[c.Name] 24 | versionsInRange := []string{} 25 | for _, v := range versions { 26 | sv, err := semver.ParseTolerant(v.Version) 27 | if err != nil { 28 | continue 29 | } 30 | if len(sv.Pre) > 0 { 31 | continue 32 | } 33 | if r(sv) { 34 | s := sv.String() 35 | if prefixV { 36 | s = "v" + s 37 | } 38 | versionsInRange = append(versionsInRange, s) 39 | } 40 | } 41 | return versionsInRange, nil 42 | } 43 | 44 | func (c Chart) ResolveVersions(settings *cli.EnvSettings) ([]string, error) { 45 | version := strings.ReplaceAll(c.Version, "v", "") 46 | r, err := semver.ParseRange(version) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | if strings.HasPrefix(c.Repo.URL, "oci://") { 52 | url, _ := strings.CutPrefix(c.Repo.URL, "oci://") 53 | tags, err := c.RegistryClient.Tags(url) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | vs := []semver.Version{} 59 | for _, t := range tags { 60 | s, err := semver.ParseTolerant(t) 61 | if err != nil { 62 | // non semver tag 63 | continue 64 | } 65 | vs = append(vs, s) 66 | } 67 | 68 | semver.Sort(vs) 69 | 70 | prefixV := strings.Contains(c.Version, "v") 71 | versionsInRange := []string{} 72 | for _, v := range vs { 73 | if len(v.Pre) > 0 { 74 | continue 75 | } 76 | if r(v) { 77 | s := v.String() 78 | if prefixV { 79 | s = "v" + s 80 | } 81 | versionsInRange = append(versionsInRange, s) 82 | } 83 | } 84 | return versionsInRange, nil 85 | } 86 | 87 | update, err := c.addToHelmRepositoryFile(settings) 88 | if err != nil { 89 | return nil, err 90 | } 91 | if update { 92 | _, err = updateRepositories(settings, false, false) 93 | if err != nil { 94 | return nil, err 95 | } 96 | } 97 | return VersionsInRange(r, c) 98 | } 99 | 100 | func (c Chart) ResolveVersion(settings *cli.EnvSettings) (string, error) { 101 | v := strings.ReplaceAll(c.Version, "*", "x") 102 | r, err := semver.ParseRange(v) 103 | if err != nil { 104 | return "", err 105 | } 106 | 107 | if strings.HasPrefix(c.Repo.URL, "oci://") { 108 | url, _ := strings.CutPrefix(c.Repo.URL, "oci://") 109 | tags, err := c.RegistryClient.Tags(url) 110 | if err != nil { 111 | return "", err 112 | } 113 | 114 | vs := []semver.Version{} 115 | for _, t := range tags { 116 | s, err := semver.ParseTolerant(t) 117 | if err != nil { 118 | // non semver tag 119 | continue 120 | } 121 | vs = append(vs, s) 122 | } 123 | 124 | semver.Sort(vs) 125 | 126 | vs2 := []string{} 127 | for _, v := range vs { 128 | if len(v.Pre) > 0 { 129 | continue 130 | } 131 | if r(v) { 132 | vs2 = append(vs2, v.String()) 133 | } 134 | } 135 | 136 | if len(vs2) == 0 { 137 | return "", fmt.Errorf("failed to resolve version for %s range; available tags: %+v", c.Version, tags) 138 | } 139 | 140 | prefixV := strings.Contains(c.Version, "v") 141 | if prefixV { 142 | return "v" + vs2[len(vs2)-1], nil 143 | } 144 | 145 | return vs2[len(vs2)-1], nil 146 | } 147 | 148 | update, err := c.addToHelmRepositoryFile(settings) 149 | if err != nil { 150 | return "", err 151 | } 152 | if update { 153 | _, err = updateRepositories(settings, false, false) 154 | if err != nil { 155 | return "", err 156 | } 157 | } 158 | 159 | indexPath := fmt.Sprintf("%s/%s-index.yaml", settings.RepositoryCache, c.Repo.Name) 160 | index, err := repo.LoadIndexFile(indexPath) 161 | if err != nil { 162 | return "", err 163 | } 164 | index.SortEntries() 165 | versions := index.Entries[c.Name] 166 | for _, v := range versions { 167 | sv, err := semver.ParseTolerant(v.Version) 168 | if err != nil { 169 | continue 170 | } 171 | if len(sv.Pre) > 0 { 172 | continue 173 | } 174 | if r(sv) { 175 | slog.Debug("Resolved chart version", slog.String("chart", c.Name), slog.String("version", sv.String())) 176 | return sv.String(), nil 177 | } 178 | } 179 | return "", xerrors.New("Not Found") 180 | } 181 | 182 | func (c Chart) LatestVersion(settings *cli.EnvSettings) (string, error) { 183 | 184 | if strings.HasPrefix(c.Repo.URL, "oci://") { 185 | url, _ := strings.CutPrefix(c.Repo.URL, "oci://") 186 | vPrefix := strings.Contains(c.Version, "v") 187 | tags, err := c.RegistryClient.Tags(url) 188 | if err != nil { 189 | return "", err 190 | } 191 | 192 | vs := []semver.Version{} 193 | for _, t := range tags { 194 | s, err := semver.ParseTolerant(t) 195 | if err != nil { 196 | // non semver tag 197 | continue 198 | } 199 | vs = append(vs, s) 200 | } 201 | 202 | semver.Sort(vs) 203 | 204 | if vPrefix { 205 | return "v" + vs[len(vs)-1].String(), nil 206 | } 207 | return vs[len(vs)-1].String(), nil 208 | } 209 | 210 | indexPath := fmt.Sprintf("%s/%s-index.yaml", settings.RepositoryCache, c.Repo.Name) 211 | index, err := repo.LoadIndexFile(indexPath) 212 | if err != nil { 213 | return "", err 214 | } 215 | index.SortEntries() 216 | res := "Not Found" 217 | versions := index.Entries[c.Name] 218 | for _, v := range versions { 219 | sv, err := semver.Parse(v.Version) 220 | if err != nil { 221 | res = v.Version 222 | break 223 | } 224 | if len(sv.Pre) == 0 { 225 | res = sv.String() 226 | break 227 | } 228 | } 229 | return res, nil 230 | } 231 | 232 | type FunctionLoader struct { 233 | LoadFunc func(indexFilePath string) (*repo.IndexFile, error) 234 | } 235 | 236 | func (fl *FunctionLoader) LoadIndexFile(indexFilePath string) (*repo.IndexFile, error) { 237 | return fl.LoadFunc(indexFilePath) 238 | } 239 | -------------------------------------------------------------------------------- /pkg/helm/chart_version_test.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/blang/semver/v4" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | "helm.sh/helm/v3/pkg/chart" 10 | "helm.sh/helm/v3/pkg/cli" 11 | "helm.sh/helm/v3/pkg/repo" 12 | ) 13 | 14 | type MockIndexFileLoader struct { 15 | mock.Mock 16 | } 17 | 18 | func (m *MockIndexFileLoader) LoadIndexFile(indexFilePath string) (*repo.IndexFile, error) { 19 | args := m.Called(indexFilePath) 20 | return args.Get(0).(*repo.IndexFile), args.Error(1) 21 | } 22 | 23 | func TestVersionsInRange(t *testing.T) { 24 | mockRepoIndex := &repo.IndexFile{ 25 | Entries: map[string]repo.ChartVersions{ 26 | "testchart": { 27 | { 28 | Metadata: &chart.Metadata{ 29 | Name: "testchart", 30 | Version: "1.0.0", 31 | }, 32 | }, 33 | { 34 | Metadata: &chart.Metadata{ 35 | Name: "testchart", 36 | Version: "1.1.0", 37 | }, 38 | }, 39 | }, 40 | }, 41 | } 42 | 43 | mockLoader := new(MockIndexFileLoader) 44 | mockLoader.On("LoadIndexFile", mock.Anything).Return(mockRepoIndex, nil) 45 | 46 | r, _ := semver.ParseRange(">= 1.0.0") 47 | c := Chart{ 48 | Repo: repo.Entry{Name: "testrepo"}, 49 | Name: "testchart", 50 | Version: "1.0.0", 51 | IndexFileLoader: mockLoader, 52 | } 53 | versions, err := VersionsInRange(r, c) 54 | assert.NoError(t, err) 55 | assert.Equal(t, []string{"1.1.0", "1.0.0"}, versions) 56 | 57 | mockLoader.AssertExpectations(t) 58 | } 59 | 60 | func TestResolveVersions(t *testing.T) { 61 | mockClient := new(MockRegistryClient) 62 | 63 | c := Chart{ 64 | Repo: repo.Entry{ 65 | URL: "oci://localhost:5000/testchart", 66 | }, 67 | Name: "testchart", 68 | Version: ">= 1.0.0", 69 | PlainHTTP: true, 70 | RegistryClient: mockClient, 71 | } 72 | 73 | settings := cli.New() 74 | 75 | mockClient.On("Tags", mock.Anything).Return([]string{"1.0.0", "1.1.0"}, nil) 76 | 77 | versions, err := c.ResolveVersions(settings) 78 | assert.NoError(t, err) 79 | assert.Equal(t, []string{"1.0.0", "1.1.0"}, versions) 80 | } 81 | 82 | func TestResolveVersion(t *testing.T) { 83 | mockClient := new(MockRegistryClient) 84 | 85 | settings := cli.New() 86 | 87 | c := Chart{ 88 | Repo: repo.Entry{ 89 | URL: "oci://localhost:5000/testchart", 90 | }, 91 | Name: "testchart", 92 | Version: ">= 1.0.0", 93 | PlainHTTP: true, 94 | RegistryClient: mockClient, 95 | } 96 | 97 | mockClient.On("Tags", mock.Anything).Return([]string{"1.0.0", "1.1.0"}, nil) 98 | 99 | version, err := c.ResolveVersion(settings) 100 | assert.NoError(t, err) 101 | assert.Equal(t, "1.1.0", version) 102 | } 103 | 104 | func TestLatestVersion(t *testing.T) { 105 | mockClient := new(MockRegistryClient) 106 | 107 | c := Chart{ 108 | Repo: repo.Entry{ 109 | URL: "oci://localhost:5000/testchart", 110 | }, 111 | Name: "testchart", 112 | Version: ">= 1.0.0", 113 | PlainHTTP: true, 114 | RegistryClient: mockClient, 115 | } 116 | 117 | settings := cli.New() 118 | 119 | mockClient.On("Tags", mock.Anything).Return([]string{"1.0.0", "1.1.0"}, nil) 120 | 121 | version, err := c.LatestVersion(settings) 122 | assert.NoError(t, err) 123 | assert.Equal(t, "1.1.0", version) 124 | } 125 | -------------------------------------------------------------------------------- /pkg/helm/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package Helm is a convenience adapter for the Helm Go SDK. The package facilitates pulling, parsing and pushing charts to/from registries using the Helm SDK. 3 | The package also provides functionality to scan Helm Charts for container image references. 4 | */ 5 | 6 | package helm 7 | -------------------------------------------------------------------------------- /pkg/helm/indexFileLoader.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "go.uber.org/fx" 5 | "helm.sh/helm/v3/pkg/repo" 6 | ) 7 | 8 | type IndexFileLoader interface { 9 | LoadIndexFile(indexFilePath string) (*repo.IndexFile, error) 10 | } 11 | 12 | type DefaultIndexFileLoader struct{} 13 | 14 | func (d *DefaultIndexFileLoader) LoadIndexFile(indexFilePath string) (*repo.IndexFile, error) { 15 | return repo.LoadIndexFile(indexFilePath) 16 | } 17 | 18 | var IndexFileLoaderModule = fx.Provide(FunctionLoader{LoadFunc: repo.LoadIndexFile}) 19 | -------------------------------------------------------------------------------- /pkg/helm/option.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | type Options struct { 4 | Verbose bool 5 | Update bool 6 | K8SVersion string 7 | } 8 | 9 | type Option func(*Options) 10 | 11 | func Verbose(b bool) Option { 12 | return func(args *Options) { 13 | args.Verbose = b 14 | } 15 | } 16 | 17 | func Update(b bool) Option { 18 | return func(args *Options) { 19 | args.Update = b 20 | } 21 | } 22 | 23 | func K8SVersion(v string) Option { 24 | return func(args *Options) { 25 | args.K8SVersion = v 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/helm/parser.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "fmt" 5 | 6 | "strings" 7 | 8 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 9 | "github.com/ChristofferNissen/helmper/pkg/image" 10 | "github.com/ChristofferNissen/helmper/pkg/util/ternary" 11 | "github.com/distribution/reference" 12 | ) 13 | 14 | // traverse helm chart values to determine if condition is met 15 | func ConditionMet(condition string, values map[string]any) bool { 16 | pos := values 17 | enabled := false 18 | for _, e := range strings.Split(condition, ".") { 19 | switch v := pos[e].(type) { 20 | case string: 21 | enabled = v == "true" 22 | case bool: 23 | enabled = v 24 | case map[string](any): 25 | pos = v 26 | case interface{}: 27 | pos = pos[e].(map[string]any) 28 | } 29 | } 30 | return enabled 31 | } 32 | 33 | // traverse helm chart values data structure 34 | func findImageReferencesAcc(data map[string]any, values map[string]any, useCustomValues bool, acc string) map[*image.Image][]string { 35 | res := make(map[*image.Image][]string) 36 | 37 | i := to.Ptr(image.Image{}) 38 | for k, v := range data { 39 | switch v := v.(type) { 40 | 41 | // yaml key-value pair value type 42 | case bool: 43 | switch k { 44 | case "useDigest": 45 | i.UseDigest = v 46 | } 47 | case string: 48 | found := true 49 | 50 | switch k { 51 | case "registry": 52 | switch useCustomValues { 53 | case true: 54 | s, ok := values[k].(string) 55 | if ok { 56 | i.Registry = s 57 | } else { 58 | i.Registry = v 59 | } 60 | case false: 61 | i.Registry = v 62 | } 63 | case "repository": 64 | switch useCustomValues { 65 | case true: 66 | s, ok := values[k].(string) 67 | if ok { 68 | i.Repository = s 69 | } else { 70 | i.Repository = v 71 | } 72 | case false: 73 | i.Repository = v 74 | } 75 | case "image": 76 | switch useCustomValues { 77 | case true: 78 | s, ok := values[k].(string) 79 | if ok { 80 | i.Repository = s 81 | } else { 82 | i.Repository = v 83 | } 84 | case false: 85 | i.Repository = v 86 | } 87 | case "tag": 88 | switch useCustomValues { 89 | case true: 90 | s, ok := values[k].(string) 91 | if ok { 92 | i.Tag = s 93 | } else { 94 | i.Tag = v 95 | } 96 | case false: 97 | i.Tag = v 98 | } 99 | case "digest": 100 | switch useCustomValues { 101 | case true: 102 | s, ok := values[k].(string) 103 | if ok { 104 | i.Digest = s 105 | } else { 106 | i.Digest = v 107 | } 108 | case false: 109 | i.Digest = v 110 | } 111 | case "sha": 112 | switch useCustomValues { 113 | case true: 114 | s, ok := values[k].(string) 115 | if ok { 116 | i.Digest = s 117 | } else { 118 | i.Digest = v 119 | } 120 | case false: 121 | i.Digest = v 122 | } 123 | default: 124 | found = false 125 | } 126 | 127 | if found { 128 | res[i] = append(res[i], fmt.Sprintf("%s.%s", acc, k)) 129 | } 130 | 131 | // nested yaml object 132 | case map[string]any: 133 | // same path in yaml 134 | 135 | // Only parsed enabled sections 136 | enabled := true 137 | for k1, v1 := range v { 138 | if k1 == "enabled" { 139 | switch value := v1.(type) { 140 | case string: 141 | enabled = value == "true" 142 | case bool: 143 | enabled = ConditionMet(k1, values[k].(map[string]any)) 144 | } 145 | } 146 | } 147 | 148 | // if enabled, parse nested section 149 | if enabled { 150 | path := ternary.Ternary(acc == "", k, fmt.Sprintf("%s.%s", acc, k)) 151 | nestedRes := findImageReferencesAcc(v, values[k].(map[string]any), useCustomValues, path) 152 | for k, v := range nestedRes { 153 | res[k] = v 154 | } 155 | } 156 | } 157 | } 158 | 159 | return res 160 | } 161 | 162 | func findImageReferences(data map[string]any, values map[string]any, useCustomValues bool) map[*image.Image][]string { 163 | return findImageReferencesAcc(data, values, useCustomValues, "") 164 | } 165 | 166 | // traverse helm chart values data structure 167 | func replaceImageReferences(data map[string]any, reg string, prefixSource bool) { 168 | 169 | // For images we do not use the prefix and suffix of the registry 170 | reg, _ = strings.CutPrefix(reg, "oci://") 171 | 172 | convert := func(val string) string { 173 | ref, err := reference.ParseAnyReference(val) 174 | if err != nil { 175 | return "" 176 | } 177 | r := ref.(reference.Named) 178 | dom := reference.Domain(r) 179 | 180 | source := strings.Split(dom, ":")[0] 181 | source = strings.Split(source, ".")[0] 182 | source = "/" + source 183 | if prefixSource { 184 | reg = reg + source 185 | } 186 | 187 | if strings.Contains(val, dom) { 188 | return strings.Replace(ref.String(), dom, reg, 1) 189 | } else { 190 | if strings.HasPrefix(ref.String(), "docker.io/library/") { 191 | return reg + "/library/" + val 192 | } 193 | return reg + "/" + val 194 | } 195 | } 196 | 197 | old, ok := data["registry"].(string) 198 | if ok { 199 | data["registry"] = reg 200 | if prefixSource { 201 | repository, ok := data["repository"].(string) 202 | if ok { 203 | source := strings.Split(old, ":")[0] 204 | source = strings.Split(source, ".")[0] 205 | old = source + "/" + repository 206 | 207 | data["repository"] = old 208 | } 209 | } 210 | return 211 | } 212 | 213 | image, ok := data["image"].(string) 214 | if ok { 215 | data["image"] = convert(image) 216 | return 217 | } 218 | 219 | repository, ok := data["repository"].(string) 220 | if ok { 221 | data["repository"] = convert(repository) 222 | return 223 | } 224 | 225 | for k, v := range data { 226 | switch v.(type) { 227 | // nested yaml object 228 | case map[string]any: 229 | replaceImageReferences(data[k].(map[string]any), reg, prefixSource) 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /pkg/helm/parser_test.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import "testing" 4 | 5 | func TestConditionMet(t *testing.T) { 6 | type input struct { 7 | condition string 8 | values map[string]any 9 | expectedResult bool 10 | } 11 | 12 | tests := []input{ 13 | { 14 | condition: "test.enabled", 15 | values: map[string]any{ 16 | "test": map[string]any{ 17 | "enabled": true, 18 | }, 19 | }, 20 | expectedResult: true, 21 | }, 22 | { 23 | condition: "test.enabled", 24 | values: map[string]any{ 25 | "test": map[string]any{ 26 | "enabled": false, 27 | }, 28 | }, 29 | expectedResult: false, 30 | }, 31 | { 32 | condition: "service.enabled", 33 | values: map[string]any{ 34 | "test": map[string]any{ 35 | "enabled": true, 36 | }, 37 | }, 38 | expectedResult: false, 39 | }, 40 | { 41 | condition: "service.enabled", 42 | values: map[string]any{ 43 | "test": map[string]any{ 44 | "enabled": true, 45 | }, 46 | "service": map[string]any{ 47 | "enabled": true, 48 | }, 49 | }, 50 | expectedResult: true, 51 | }, 52 | { 53 | condition: "service.enabled", 54 | values: map[string]any{ 55 | "test": map[string]any{ 56 | "enabled": true, 57 | }, 58 | "other": map[string]any{ 59 | "service": map[string]any{ 60 | "enabled": true, 61 | }, 62 | }, 63 | }, 64 | expectedResult: false, 65 | }, 66 | } 67 | 68 | for _, test := range tests { 69 | res := ConditionMet(test.condition, test.values) 70 | if res != test.expectedResult { 71 | t.Errorf("got '%t' want '%t'", res, test.expectedResult) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/helm/registryClient.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "go.uber.org/fx" 5 | helm_registry "helm.sh/helm/v3/pkg/registry" 6 | ) 7 | 8 | // Define the interface for the registry client 9 | type RegistryClient interface { 10 | Pull(ref string, opts ...helm_registry.PullOption) (*helm_registry.PullResult, error) 11 | Push(chart []byte, destination string, opts ...helm_registry.PushOption) (*helm_registry.PushResult, error) 12 | Tags(ref string) ([]string, error) 13 | } 14 | 15 | // Default registry client provider 16 | func NewDefaultRegistryClient() (RegistryClient, error) { 17 | plainHTTP := false 18 | debug := false 19 | 20 | return NewRegistryClient(plainHTTP, debug) 21 | } 22 | 23 | func NewRegistryClient(plainHTTP, debug bool) (RegistryClient, error) { 24 | opts := []helm_registry.ClientOption{} 25 | if plainHTTP { 26 | opts = append(opts, helm_registry.ClientOptPlainHTTP()) 27 | } 28 | if debug { 29 | opts = append(opts, helm_registry.ClientOptDebug(true)) 30 | } 31 | return helm_registry.NewClient(opts...) 32 | } 33 | 34 | var RegistryModule = fx.Provide(NewDefaultRegistryClient) 35 | -------------------------------------------------------------------------------- /pkg/helm/types.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "github.com/ChristofferNissen/helmper/pkg/image" 5 | "github.com/ChristofferNissen/helmper/pkg/registry" 6 | "helm.sh/helm/v3/pkg/chart" 7 | "helm.sh/helm/v3/pkg/repo" 8 | ) 9 | 10 | type Images struct { 11 | Exclude []struct { 12 | Ref string `json:"ref"` 13 | } `json:"exclude"` 14 | ExcludeCopacetic []struct { 15 | Ref string `json:"ref"` 16 | } `json:"excludeCopacetic"` 17 | Modify []struct { 18 | From string `json:"from"` 19 | FromValuePath string `json:"fromValuePath"` 20 | To string `json:"to"` 21 | } `json:"modify"` 22 | } 23 | 24 | type Chart struct { 25 | Name string `json:"name"` 26 | Version string `json:"version"` 27 | ValuesFilePath string `json:"valuesFilePath"` 28 | Values map[string]any `json:"values,omitempty"` 29 | Repo repo.Entry `json:"repo"` 30 | Parent *Chart 31 | Images *Images `json:"images"` 32 | PlainHTTP bool `json:"plainHTTP"` 33 | DepsCount int 34 | RegistryClient RegistryClient 35 | IndexFileLoader IndexFileLoader 36 | } 37 | 38 | type ChartCollection struct { 39 | Charts []*Chart `json:"charts"` 40 | } 41 | 42 | // channels to share data between goroutines 43 | type chartInfo struct { 44 | chartRef *chart.Chart 45 | *Chart 46 | } 47 | 48 | type imageInfo struct { 49 | available bool 50 | chart *Chart 51 | image *image.Image 52 | collection *[]string 53 | } 54 | 55 | type ChartData map[*Chart]map[*image.Image][]string 56 | 57 | type RegistryChartStatus map[*registry.Registry]map[*Chart]bool 58 | 59 | type RegistryImageStatus map[*registry.Registry]map[*image.Image]bool 60 | 61 | type Mirror struct { 62 | Registry string `yaml:"registry"` 63 | Mirror string `yaml:"mirror"` 64 | } 65 | -------------------------------------------------------------------------------- /pkg/helm/update.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/hashicorp/go-retryablehttp" 9 | "golang.org/x/exp/slog" 10 | "helm.sh/helm/v3/pkg/cli" 11 | "helm.sh/helm/v3/pkg/downloader" 12 | "helm.sh/helm/v3/pkg/getter" 13 | "helm.sh/helm/v3/pkg/registry" 14 | ) 15 | 16 | // Contructs a Helm Chart Downloader Manager from Helm SDK 17 | func getManager(settings *cli.EnvSettings, out *bytes.Buffer, verbose bool, update bool) downloader.Manager { 18 | httpGetter := func(options ...getter.Option) (getter.Getter, error) { 19 | // Get retryable logic 20 | retryClient := retryablehttp.NewClient() 21 | retryClient.RetryMax = 10 22 | retryClient.RetryWaitMin = time.Second * 1 23 | retryClient.RetryWaitMax = time.Second * 10 24 | transport := retryClient.HTTPClient.Transport.(*http.Transport) 25 | 26 | // Set options 27 | o1 := getter.WithTimeout(10 * time.Second) 28 | o2 := getter.WithTransport(transport) 29 | opts := append(options, []getter.Option{o1, o2}...) 30 | 31 | // return curried function 32 | return getter.NewHTTPGetter(opts...) 33 | } 34 | 35 | // TODO: Handle error 36 | rClient, _ := registry.NewRegistryClientWithTLS(out, "", "", "", false, settings.RegistryConfig, false) 37 | // if err != nil { 38 | 39 | // } 40 | return downloader.Manager{ 41 | Out: out, 42 | RegistryClient: rClient, 43 | RepositoryConfig: settings.RepositoryConfig, 44 | RepositoryCache: settings.RepositoryCache, 45 | Verify: downloader.VerifyIfPossible, 46 | Debug: verbose, 47 | SkipUpdate: !update, 48 | Getters: []getter.Provider{ 49 | { 50 | Schemes: []string{registry.OCIScheme}, 51 | New: getter.NewOCIGetter, 52 | }, 53 | { 54 | Schemes: []string{"http", "https"}, 55 | New: httpGetter, 56 | }, 57 | }, 58 | } 59 | } 60 | 61 | func updateRepository(settings *cli.EnvSettings, path string, opts ...Option) error { 62 | 63 | // Default Options 64 | args := &Options{ 65 | Verbose: false, 66 | Update: false, 67 | } 68 | 69 | for _, opt := range opts { 70 | opt(args) 71 | } 72 | 73 | // Update Helm Repos 74 | var out bytes.Buffer 75 | ma := getManager(settings, &out, args.Verbose, args.Update) 76 | if args.Verbose { 77 | slog.Info(out.String()) 78 | } 79 | ma.ChartPath = path 80 | return ma.Update() 81 | } 82 | 83 | // update all repositories in local configuration file 84 | func updateRepositories(settings *cli.EnvSettings, verbose, update bool) (string, error) { 85 | 86 | // Update Helm Repos 87 | var out bytes.Buffer 88 | ma := getManager(settings, &out, verbose, update) 89 | 90 | err := ma.UpdateRepositories() 91 | if err != nil { 92 | return "", err 93 | } 94 | 95 | return out.String(), nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/image/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package Image implements Container Image interactions. 3 | */ 4 | 5 | package image 6 | -------------------------------------------------------------------------------- /pkg/image/image.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 8 | "github.com/distribution/reference" 9 | ) 10 | 11 | // UpdateNameWithPrefixSource updates the image name with the registry prefix if PrefixSource is enabled. 12 | func UpdateNameWithPrefixSource(i *Image) (string, error) { 13 | name, err := i.ImageName() 14 | if err != nil { 15 | return "", err 16 | } 17 | reg, _, _, err := i.Elements() 18 | if err != nil { 19 | return "", err 20 | } 21 | noPorts := strings.SplitN(reg, ":", 2)[0] 22 | noTLD := strings.SplitN(noPorts, ".", 2)[0] 23 | return noTLD + "/" + name, nil 24 | } 25 | 26 | // RefToImage parses the reference string and returns an Image. 27 | func RefToImage(r string) (Image, error) { 28 | ref, err := reference.ParseAnyReference(r) 29 | if err != nil { 30 | return Image{}, fmt.Errorf("failed to parse reference: %w", err) 31 | } 32 | 33 | img := Image{} 34 | 35 | switch r := ref.(type) { 36 | case reference.Canonical: 37 | img.Registry = reference.Domain(r) 38 | img.Repository = reference.Path(r) 39 | img.Digest = r.Digest().String() 40 | img.UseDigest = true 41 | if t, ok := r.(reference.Tagged); ok { 42 | img.Tag = t.Tag() 43 | } 44 | case reference.NamedTagged: 45 | img.Registry = reference.Domain(r) 46 | img.Repository = reference.Path(r) 47 | img.Tag = r.Tag() 48 | img.UseDigest = false 49 | default: 50 | return img, fmt.Errorf("image reference not understood") 51 | } 52 | 53 | return img, nil 54 | } 55 | 56 | type Image struct { 57 | Registry string 58 | Repository string 59 | Tag string 60 | Digest string 61 | UseDigest bool 62 | Patch *bool 63 | parsedRef *string 64 | } 65 | 66 | func (i *Image) ResetParsedRef() { 67 | // Remove the assignment to i.parsedRef 68 | i.parsedRef = nil 69 | } 70 | 71 | // IsEmpty determines if an image is empty (i.e., registry, repository, and name are empty). 72 | func (i Image) IsEmpty() bool { 73 | return i.Registry == "" && i.Repository == "" && i.Tag == "" 74 | } 75 | 76 | // TagOrDigest returns a string representation of either the tag or digest. 77 | func (i Image) TagOrDigest() (string, error) { 78 | switch { 79 | case i.Tag != "" && i.Digest != "": 80 | return fmt.Sprintf("%s@%s", i.Tag, i.Digest), nil 81 | case i.Tag == "" && i.Digest != "": 82 | return i.Digest, nil 83 | case i.Tag != "" && i.Digest == "": 84 | return i.Tag, nil 85 | default: 86 | return "", fmt.Errorf("no tag or digest") 87 | } 88 | } 89 | 90 | // String returns the string representation of the image. 91 | 92 | func cleanString(s string, remove string) string { 93 | noSuffix, _ := strings.CutSuffix(s, remove) 94 | noPrefix, _ := strings.CutPrefix(noSuffix, remove) 95 | return noPrefix 96 | } 97 | 98 | func (i *Image) String() string { 99 | if i.parsedRef != nil { 100 | return *i.parsedRef 101 | } 102 | 103 | var refBuilder strings.Builder 104 | 105 | // Join the registry and repository 106 | if i.Registry != "" { 107 | refBuilder.WriteString(cleanString(i.Registry, "/")) 108 | refBuilder.WriteString("/") 109 | } 110 | refBuilder.WriteString(cleanString(i.Repository, "/")) 111 | 112 | // Append tag if present 113 | if i.Tag != "" { 114 | refBuilder.WriteString(":") 115 | refBuilder.WriteString(cleanString(i.Tag, ":")) 116 | } 117 | 118 | // Append digest if needed 119 | if i.UseDigest && i.Digest != "" { 120 | refBuilder.WriteString("@") 121 | refBuilder.WriteString(cleanString(i.Digest, "@")) 122 | } 123 | 124 | ref := refBuilder.String() 125 | res, err := reference.ParseAnyReference(ref) 126 | if err != nil { 127 | return ref 128 | } 129 | 130 | if !strings.HasPrefix(res.String(), i.Registry) { 131 | return ref 132 | } 133 | 134 | s := res.String() 135 | i.parsedRef = to.Ptr(s) 136 | return s 137 | } 138 | 139 | // Elements returns the registry, repository, and name of the image. 140 | func (i Image) Elements() (string, string, string, error) { 141 | ref := i.String() 142 | res, err := reference.ParseNamed(ref) 143 | if err != nil { 144 | return "", "", "", err 145 | } 146 | 147 | switch r := res.(type) { 148 | case reference.Named: 149 | withoutDomain := strings.TrimPrefix(r.Name(), reference.Domain(r)+"/") 150 | parts := strings.Split(withoutDomain, "/") 151 | var repository, name string 152 | if len(parts) == 2 { 153 | repository = parts[0] 154 | name = parts[1] 155 | } else if len(parts) > 2 { 156 | repository = strings.Join(parts[:2], "/") 157 | name = strings.Join(parts[2:], "/") 158 | } else { 159 | repository = "library" 160 | name = parts[0] 161 | } 162 | return reference.Domain(r), repository, name, nil 163 | default: 164 | return "", "", "", fmt.Errorf("failed to parse elements") 165 | } 166 | } 167 | 168 | // ImageName returns the full name of the image. 169 | func (i Image) ImageName() (string, error) { 170 | res, err := reference.ParseNamed(i.String()) 171 | if err != nil { 172 | return "", fmt.Errorf("failed to parse reference: %w", err) 173 | } 174 | switch r := res.(type) { 175 | case reference.Named: 176 | withoutDomain := strings.TrimPrefix(r.Name(), reference.Domain(r)+"/") 177 | return withoutDomain, nil 178 | default: 179 | return "", fmt.Errorf("image could not be parsed") 180 | } 181 | } 182 | 183 | // In checks if the image is in a slice of images. 184 | func (i *Image) In(s []Image) bool { 185 | for _, e := range s { 186 | if i.Registry == e.Registry && i.Repository == e.Repository && i.Tag == e.Tag { 187 | return true 188 | } 189 | } 190 | return false 191 | } 192 | 193 | // InP checks if the image is in a slice of pointers to images. 194 | func (i *Image) InP(s []*Image) bool { 195 | for _, e := range s { 196 | if i.Registry == e.Registry && i.Repository == e.Repository && i.Tag == e.Tag { 197 | return true 198 | } 199 | } 200 | return false 201 | } 202 | 203 | func (i *Image) ReplaceRegistry(new string) string { 204 | i.Registry = new 205 | i.parsedRef = nil 206 | return i.String() 207 | } 208 | -------------------------------------------------------------------------------- /pkg/image/image_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "golang.org/x/xerrors" 8 | ) 9 | 10 | func testBed() []Image { 11 | return []Image{ 12 | {Repository: "hello-world", Tag: "latest"}, 13 | {Registry: "quay.io", Repository: "argoproj/argocd", Tag: "v2.10.0"}, 14 | {Repository: "ghcr.io/kubereboot/kured", Tag: "1.14.1"}, 15 | {Repository: "ghcr.io/kubereboot/kured", Digest: "sha256:ca4ae4f37d71a4110889fc4add3c4abef8b96fa6ed977ed399d9b1c3bd7e608e"}, 16 | {Repository: "ghcr.io/kubereboot/kured"}, 17 | {Repository: "ghcr.io/kubereboot/kured", Tag: "1.14.1", Digest: "sha256:ca4ae4f37d71a4110889fc4add3c4abef8b96fa6ed977ed399d9b1c3bd7e608e"}, 18 | {Registry: "public.ecr.aws", Repository: "eks-distro/kubernetes-csi/livenessprobe", Tag: "v2.13.0-eks-1-30-8"}, 19 | } 20 | } 21 | 22 | func TestIsEmpty(t *testing.T) { 23 | tests := []struct { 24 | img Image 25 | expected bool 26 | }{ 27 | {Image{Registry: "", Repository: "", Tag: ""}, true}, 28 | {Image{Registry: "docker.io", Repository: "", Tag: ""}, false}, 29 | {Image{Registry: "", Repository: "library/hello-world", Tag: ""}, false}, 30 | {Image{Registry: "", Repository: "", Tag: "latest"}, false}, 31 | {Image{Registry: "docker.io", Repository: "library/hello-world", Tag: "latest"}, false}, 32 | } 33 | 34 | for _, tt := range tests { 35 | result := tt.img.IsEmpty() 36 | assert.Equal(t, tt.expected, result) 37 | } 38 | } 39 | 40 | func TestUpdateNameWithPrefixSource(t *testing.T) { 41 | img := Image{Repository: "library/hello-world"} 42 | expected := "docker/library/hello-world" 43 | result, err := UpdateNameWithPrefixSource(&img) 44 | assert.NoError(t, err, "expected no error") 45 | assert.Equal(t, expected, result) 46 | } 47 | 48 | func TestRefToImage(t *testing.T) { 49 | ref := "docker.io/library/hello-world:latest" 50 | img, err := RefToImage(ref) 51 | assert.NoError(t, err) 52 | assert.Equal(t, "docker.io", img.Registry) 53 | assert.Equal(t, "library/hello-world", img.Repository) 54 | assert.Equal(t, "latest", img.Tag) 55 | } 56 | 57 | func TestTagOrDigest(t *testing.T) { 58 | imgs := testBed() 59 | 60 | tests := []struct { 61 | img Image 62 | expected string 63 | wantErr bool 64 | }{ 65 | {imgs[2], "1.14.1", false}, 66 | {imgs[3], "sha256:ca4ae4f37d71a4110889fc4add3c4abef8b96fa6ed977ed399d9b1c3bd7e608e", false}, 67 | {imgs[4], "", true}, 68 | {imgs[5], "1.14.1@sha256:ca4ae4f37d71a4110889fc4add3c4abef8b96fa6ed977ed399d9b1c3bd7e608e", false}, 69 | } 70 | 71 | for _, tt := range tests { 72 | result, err := tt.img.TagOrDigest() 73 | if tt.wantErr { 74 | assert.Error(t, err, "expected an error") 75 | assert.Equal(t, xerrors.Errorf("no tag or digest").Error(), err.Error()) 76 | } else { 77 | assert.NoError(t, err) 78 | assert.Equal(t, tt.expected, result) 79 | } 80 | } 81 | } 82 | 83 | func TestString(t *testing.T) { 84 | imgs := testBed() 85 | 86 | tests := []struct { 87 | img Image 88 | expected string 89 | }{ 90 | {imgs[0], "docker.io/library/hello-world:latest"}, 91 | {imgs[1], "quay.io/argoproj/argocd:v2.10.0"}, 92 | {imgs[2], "ghcr.io/kubereboot/kured:1.14.1"}, 93 | } 94 | 95 | for _, tt := range tests { 96 | result := tt.img.String() 97 | // assert.NoError(t, err) 98 | assert.Equal(t, tt.expected, result) 99 | } 100 | } 101 | 102 | func TestElements(t *testing.T) { 103 | imgs := testBed() 104 | 105 | tests := []struct { 106 | img Image 107 | expectedReg string 108 | expectedRepo string 109 | expectedName string 110 | expectedTagOrDigest string 111 | }{ 112 | {imgs[0], "docker.io", "library", "hello-world", "latest"}, 113 | {imgs[1], "quay.io", "argoproj", "argocd", "v2.10.0"}, 114 | {imgs[2], "ghcr.io", "kubereboot", "kured", "1.14.1"}, 115 | {imgs[3], "ghcr.io", "kubereboot", "kured", "sha256:ca4ae4f37d71a4110889fc4add3c4abef8b96fa6ed977ed399d9b1c3bd7e608e"}, 116 | {imgs[6], "public.ecr.aws", "eks-distro/kubernetes-csi", "livenessprobe", "v2.13.0-eks-1-30-8"}, 117 | } 118 | 119 | for _, tt := range tests { 120 | reg, repo, name, _ := tt.img.Elements() 121 | tagOrDigest, err := tt.img.TagOrDigest() 122 | assert.NoError(t, err, "expected no errors") 123 | assert.Equal(t, tt.expectedReg, reg) 124 | assert.Equal(t, tt.expectedRepo, repo) 125 | assert.Equal(t, tt.expectedName, name) 126 | assert.Equal(t, tt.expectedTagOrDigest, tagOrDigest) 127 | } 128 | } 129 | 130 | func TestImageName(t *testing.T) { 131 | imgs := testBed() 132 | 133 | tests := []struct { 134 | img Image 135 | expected string 136 | }{ 137 | {imgs[0], "library/hello-world"}, 138 | {imgs[1], "argoproj/argocd"}, 139 | {imgs[2], "kubereboot/kured"}, 140 | {imgs[3], "kubereboot/kured"}, 141 | } 142 | 143 | for _, tt := range tests { 144 | result, err := tt.img.ImageName() 145 | assert.NoError(t, err) 146 | assert.Equal(t, tt.expected, result) 147 | } 148 | } 149 | 150 | func TestIn(t *testing.T) { 151 | img := Image{Registry: "docker.io", Repository: "library/hello-world", Tag: "latest"} 152 | images := []Image{ 153 | {Registry: "docker.io", Repository: "library/hello-world", Tag: "latest"}, 154 | {Registry: "quay.io", Repository: "argoproj/argocd", Tag: "v2.10.0"}, 155 | } 156 | 157 | result := img.In(images) 158 | assert.True(t, result) 159 | 160 | imgNotExist := Image{Registry: "docker.io", Repository: "library/hello-world", Tag: "1.0"} 161 | result = imgNotExist.In(images) 162 | assert.False(t, result) 163 | } 164 | 165 | func TestInP(t *testing.T) { 166 | img := Image{Registry: "docker.io", Repository: "library/hello-world", Tag: "latest"} 167 | images := []*Image{ 168 | {Registry: "docker.io", Repository: "library/hello-world", Tag: "latest"}, 169 | {Registry: "quay.io", Repository: "argoproj/argocd", Tag: "v2.10.0"}, 170 | } 171 | 172 | result := img.InP(images) 173 | assert.True(t, result) 174 | 175 | imgNotExist := Image{Registry: "docker.io", Repository: "library/hello-world", Tag: "1.0"} 176 | result = imgNotExist.InP(images) 177 | assert.False(t, result) 178 | } 179 | 180 | func TestImageNameError(t *testing.T) { 181 | img := Image{Registry: "invalid_registry", Repository: "repo"} 182 | res, err := img.ImageName() 183 | assert.Error(t, err, "Expected error for invalid image name") 184 | assert.Empty(t, res, "expected empty name") 185 | } 186 | 187 | func TestElementsError(t *testing.T) { 188 | img := Image{Registry: "invalid_registry", Repository: "repo"} 189 | reg, repo, name, err := img.Elements() 190 | assert.Error(t, err, "Expected error for invalid registry") 191 | assert.Empty(t, reg, "Expected empty registry") 192 | assert.Empty(t, repo, "Expected empty repository") 193 | assert.Empty(t, name, "Expected empty name") 194 | } 195 | 196 | func TestRefToImage_ErrorCases(t *testing.T) { 197 | tests := []struct { 198 | ref string 199 | shouldFail bool 200 | }{ 201 | {"invalid-reference@", true}, 202 | {"docker.io/library/hello-world@invalid-digest", true}, 203 | } 204 | 205 | for _, tt := range tests { 206 | _, err := RefToImage(tt.ref) 207 | if tt.shouldFail { 208 | assert.Error(t, err) 209 | } else { 210 | assert.NoError(t, err) 211 | } 212 | } 213 | } 214 | 215 | func TestUpdateNameWithPrefixSource_ErrorCases(t *testing.T) { 216 | img := Image{Registry: "invalid_registry", Repository: "repo"} 217 | result, err := UpdateNameWithPrefixSource(&img) 218 | assert.Error(t, err, "") 219 | assert.Empty(t, result) 220 | } 221 | 222 | func TestRefToImage_AdditionalErrorCases(t *testing.T) { 223 | tests := []struct { 224 | ref string 225 | shouldFail bool 226 | }{ 227 | {"", true}, 228 | {"://invalid-reference", true}, 229 | {"http://docker.io/library/hello-world", true}, 230 | } 231 | 232 | for _, tt := range tests { 233 | _, err := RefToImage(tt.ref) 234 | if tt.shouldFail { 235 | assert.Error(t, err) 236 | } else { 237 | assert.NoError(t, err) 238 | } 239 | } 240 | } 241 | 242 | func TestImageTagOrDigest_ErrorCases(t *testing.T) { 243 | img := Image{} 244 | result, err := img.TagOrDigest() 245 | assert.Empty(t, result, "Expected empty result for empty image") 246 | assert.Error(t, err, "Expected error for empty image") 247 | } 248 | 249 | func TestImageString_BoundaryCases(t *testing.T) { 250 | img := Image{Registry: "docker.io/", Repository: "/library/hello-world", Tag: "latest"} 251 | result := img.String() 252 | assert.Contains(t, result, "docker.io/library/hello-world:latest", "Expected correctly formatted string despite leading slash") 253 | } 254 | 255 | func TestIn_NullCases(t *testing.T) { 256 | img := Image{Registry: "docker.io", Repository: "library/hello-world", Tag: "latest"} 257 | images := []Image{} 258 | 259 | result := img.In(images) 260 | assert.False(t, result, "Expected false for empty images slice") 261 | 262 | resultP := img.InP(nil) 263 | assert.False(t, resultP, "Expected false for nil images slice") 264 | } 265 | 266 | func TestReplaceRegistry(t *testing.T) { 267 | tests := []struct { 268 | img Image 269 | newRegistry string 270 | expectedString string 271 | }{ 272 | { 273 | Image{Registry: "docker.io", Repository: "library/hello-world", Tag: "latest"}, 274 | "quay.io", 275 | "quay.io/library/hello-world:latest", 276 | }, 277 | { 278 | Image{Registry: "gcr.io", Repository: "k8s-artifacts-prod/gce", Tag: "v1.0.0"}, 279 | "eu.gcr.io", 280 | "eu.gcr.io/k8s-artifacts-prod/gce:v1.0.0", 281 | }, 282 | { 283 | Image{Registry: "docker.io", Repository: "library/busybox", Tag: "1.31"}, 284 | "registry.hub.docker.com", 285 | "registry.hub.docker.com/library/busybox:1.31", 286 | }, 287 | } 288 | 289 | for _, tt := range tests { 290 | result := tt.img.ReplaceRegistry(tt.newRegistry) 291 | assert.Equal(t, tt.expectedString, result) 292 | assert.Equal(t, tt.newRegistry, tt.img.Registry) 293 | assert.Equal(t, result, *tt.img.parsedRef) 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /pkg/registry/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package Registry implements Container Registry interactions. 3 | */ 4 | 5 | package registry 6 | -------------------------------------------------------------------------------- /pkg/registry/importOption.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/ChristofferNissen/helmper/pkg/image" 8 | 9 | "github.com/ChristofferNissen/helmper/pkg/util/bar" 10 | "golang.org/x/sync/errgroup" 11 | ) 12 | 13 | type ImportOption struct { 14 | Data map[*Registry]map[*image.Image]bool 15 | 16 | Architecture *string 17 | All bool 18 | } 19 | 20 | func (io ImportOption) Run(ctx context.Context) error { 21 | 22 | slog.Debug("pushing images to registries..") 23 | 24 | size := func() int { 25 | size := 0 26 | for _, m := range io.Data { 27 | for _, b := range m { 28 | if b { 29 | size++ 30 | } 31 | } 32 | } 33 | return size 34 | }() 35 | 36 | bar := bar.New("Pushing images...\r", size) 37 | 38 | eg, egCtx := errgroup.WithContext(ctx) 39 | for r, m := range io.Data { 40 | for i, b := range m { 41 | eg.Go(func() error { 42 | if io.All || b { 43 | name, err := i.ImageName() 44 | if err != nil { 45 | return err 46 | } 47 | manifest, err := r.Push(egCtx, i.Registry, name, i.Tag, io.Architecture) 48 | if err != nil { 49 | return err 50 | } 51 | i.Digest = manifest.Digest.String() 52 | _ = bar.Add(1) 53 | } 54 | return nil 55 | }) 56 | } 57 | } 58 | 59 | err := eg.Wait() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | _ = bar.Finish() 65 | slog.Debug("all images have been pushed to registries") 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/registry/registry.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "strings" 8 | 9 | v1_spec "github.com/google/go-containerregistry/pkg/v1" 10 | v1 "github.com/opencontainers/image-spec/specs-go/v1" 11 | "oras.land/oras-go/v2" 12 | "oras.land/oras-go/v2/content/memory" 13 | "oras.land/oras-go/v2/registry/remote" 14 | "oras.land/oras-go/v2/registry/remote/auth" 15 | "oras.land/oras-go/v2/registry/remote/credentials" 16 | "oras.land/oras-go/v2/registry/remote/retry" 17 | ) 18 | 19 | type Registry struct { 20 | Name string 21 | URL string 22 | Insecure bool 23 | PlainHTTP bool 24 | PrefixSource bool 25 | } 26 | 27 | type Exister interface { 28 | Exist(context.Context, string, string) (bool, error) 29 | GetName() string 30 | } 31 | 32 | var _ Exister = (*Registry)(nil) 33 | 34 | type Puller interface { 35 | Pull(context.Context, string, string) (*v1.Descriptor, error) 36 | } 37 | 38 | var _ Puller = (*Registry)(nil) 39 | 40 | type Pusher interface { 41 | Exister 42 | Push(ctx context.Context, sourceURL string, img string, tag string, arch *string) (v1.Descriptor, error) 43 | } 44 | 45 | var _ Pusher = (*Registry)(nil) 46 | 47 | func (r Registry) GetName() string { 48 | return r.Name 49 | } 50 | 51 | func newDockerCredentialsStore() (*credentials.DynamicStore, error) { 52 | storeOpts := credentials.StoreOptions{} 53 | return credentials.NewStoreFromDocker(storeOpts) 54 | } 55 | 56 | func setupRepository(baseURL string, name string, credStore *credentials.DynamicStore) (*remote.Repository, error) { 57 | ref := strings.Join([]string{baseURL, name}, "/") 58 | repo, err := remote.NewRepository(ref) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | repo.Client = &auth.Client{ 64 | Client: retry.DefaultClient, 65 | Cache: auth.NewCache(), 66 | Credential: credentials.Credential(credStore), 67 | } 68 | return repo, nil 69 | } 70 | 71 | func isLocalReference(url string) bool { 72 | return strings.Contains(url, "localhost") || strings.Contains(url, "0.0.0.0") 73 | } 74 | 75 | func parsePlatform(arch string) (*v1.Platform, error) { 76 | v, err := v1_spec.ParsePlatform(arch) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return &v1.Platform{ 81 | Architecture: v.Architecture, 82 | OS: v.OS, 83 | OSVersion: v.OSVersion, 84 | OSFeatures: v.OSFeatures, 85 | Variant: v.Variant, 86 | }, nil 87 | } 88 | 89 | // Push pushes an image to the registry. 90 | func (r Registry) Push(ctx context.Context, sourceURL string, name string, tag string, arch *string) (v1.Descriptor, error) { 91 | credStore, err := newDockerCredentialsStore() 92 | if err != nil { 93 | return v1.Descriptor{}, err 94 | } 95 | 96 | source, err := setupRepository(sourceURL, name, credStore) 97 | if err != nil { 98 | return v1.Descriptor{}, err 99 | } 100 | 101 | source.PlainHTTP = isLocalReference(sourceURL) 102 | 103 | url, _ := strings.CutPrefix(r.URL, "oci://") 104 | if r.PrefixSource { 105 | noPorts := strings.Split(sourceURL, ":")[0] 106 | noTLD := strings.Split(noPorts, ".")[0] 107 | old := name 108 | name = fmt.Sprintf("%s/%s", noTLD, name) 109 | slog.Info("registry has PrefixSource enabled", slog.String("old", old), slog.String("new", name)) 110 | } 111 | target, err := setupRepository(url, name, credStore) 112 | if err != nil { 113 | return v1.Descriptor{}, err 114 | } 115 | 116 | target.PlainHTTP = r.PlainHTTP 117 | 118 | opts := oras.DefaultCopyOptions 119 | if arch != nil { 120 | platform, err := parsePlatform(*arch) 121 | if err != nil { 122 | return v1.Descriptor{}, err 123 | } 124 | opts.WithTargetPlatform(platform) 125 | } 126 | 127 | manifest, err := oras.Copy(ctx, source, tag, target, tag, opts) 128 | if err != nil { 129 | return v1.Descriptor{}, err 130 | } 131 | 132 | return manifest, nil 133 | } 134 | 135 | func (r Registry) Fetch(ctx context.Context, name string, tag string) (*v1.Descriptor, error) { 136 | // 1. Connect to a remote repository 137 | url, _ := strings.CutPrefix(r.URL, "oci://") 138 | ref := strings.Join([]string{url, name}, "/") 139 | repo, err := remote.NewRepository(ref) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | repo.PlainHTTP = r.PlainHTTP 145 | 146 | // prepare authentication using Docker credentials 147 | storeOpts := credentials.StoreOptions{} 148 | credStore, err := credentials.NewStoreFromDocker(storeOpts) 149 | if err != nil { 150 | return nil, err 151 | } 152 | repo.Client = &auth.Client{ 153 | Client: retry.DefaultClient, 154 | Cache: auth.NewCache(), 155 | Credential: credentials.Credential(credStore), // Use the credentials store 156 | } 157 | 158 | // 2. Copy from the remote repository to the OCI layout store 159 | d, err := repo.Resolve(ctx, tag) 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | return &d, nil 165 | } 166 | 167 | func (r Registry) Pull(ctx context.Context, name string, tag string) (*v1.Descriptor, error) { 168 | // 0. Create an OCI layout store 169 | store := memory.New() 170 | 171 | // 1. Connect to a remote repository 172 | ref := strings.Join([]string{r.URL, name}, "/") 173 | repo, err := remote.NewRepository(ref) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | repo.PlainHTTP = r.PlainHTTP 179 | 180 | // prepare authentication using Docker credentials 181 | storeOpts := credentials.StoreOptions{} 182 | credStore, err := credentials.NewStoreFromDocker(storeOpts) 183 | if err != nil { 184 | return nil, err 185 | } 186 | repo.Client = &auth.Client{ 187 | Client: retry.DefaultClient, 188 | Cache: auth.NewCache(), 189 | Credential: credentials.Credential(credStore), // Use the credentials store 190 | } 191 | 192 | // 2. Copy from the remote repository to the OCI layout store 193 | d, err := oras.Copy(ctx, repo, tag, store, tag, oras.DefaultCopyOptions) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | return &d, nil 199 | } 200 | 201 | func (r Registry) Exist(ctx context.Context, name string, tag string) (bool, error) { 202 | return Exist(ctx, strings.Join([]string{r.URL, name}, "/"), tag, r.PlainHTTP) 203 | } 204 | 205 | func Exists(ctx context.Context, ref string, tag string, registries []*Registry) map[string]bool { 206 | ref, _ = strings.CutPrefix(ref, "oci://") 207 | 208 | m := make(map[string]bool, len(registries)) 209 | 210 | for _, r := range registries { 211 | exists := func(r Exister) bool { 212 | exists, err := r.Exist(ctx, ref, tag) 213 | if err != nil { 214 | return false 215 | } 216 | return exists 217 | }(r) 218 | 219 | m[r.URL] = exists 220 | } 221 | 222 | return m 223 | } 224 | 225 | func Exist(ctx context.Context, reference string, tag string, plainHTTP bool) (bool, error) { 226 | reference, _ = strings.CutPrefix(reference, "oci://") 227 | 228 | // 1. Connect to a remote repository 229 | repo, err := remote.NewRepository(reference) 230 | if err != nil { 231 | return false, err 232 | } 233 | 234 | repo.PlainHTTP = plainHTTP 235 | 236 | // prepare authentication using Docker credentials 237 | storeOpts := credentials.StoreOptions{} 238 | credStore, err := credentials.NewStoreFromDocker(storeOpts) 239 | if err != nil { 240 | return false, err 241 | } 242 | repo.Client = &auth.Client{ 243 | Client: retry.DefaultClient, 244 | Cache: auth.NewCache(), 245 | Credential: credentials.Credential(credStore), // Use the credentials store 246 | } 247 | 248 | // 2. Copy from the remote repository to the OCI layout store 249 | opts := oras.DefaultFetchOptions 250 | _, _, err = oras.Fetch(ctx, repo, tag, opts) 251 | return err == nil, err 252 | } 253 | -------------------------------------------------------------------------------- /pkg/report/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package Report contains code to generate reports while processing 3 | */ 4 | 5 | package report 6 | -------------------------------------------------------------------------------- /pkg/report/types.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/jedib0t/go-pretty/v6/table" 7 | ) 8 | 9 | type Table struct { 10 | writer table.Writer 11 | } 12 | 13 | // NewTable creates a new Table with a title and header 14 | func NewTable(title string) *Table { 15 | t := table.NewWriter() 16 | t.SetTitle(title) 17 | t.SetOutputMirror(os.Stdout) 18 | return &Table{writer: t} 19 | } 20 | 21 | // AddRow adds a row to the table 22 | func (t *Table) AddRow(row table.Row) { 23 | t.writer.AppendRow(row) 24 | } 25 | 26 | // AddHeader adds a row to the table as header 27 | func (t *Table) AddHeader(header table.Row, configs ...table.RowConfig) { 28 | t.writer.AppendHeader(header, configs...) 29 | } 30 | 31 | // AddFooter adds a row to the table as footer 32 | func (t *Table) AddFooter(footer table.Row, configs ...table.RowConfig) { 33 | t.writer.AppendFooter(footer, configs...) 34 | } 35 | 36 | // Render renders the table 37 | func (t *Table) Render() { 38 | t.writer.Render() 39 | } 40 | -------------------------------------------------------------------------------- /pkg/report/util.go: -------------------------------------------------------------------------------- 1 | package report 2 | 3 | import "github.com/ChristofferNissen/helmper/pkg/util/file" 4 | 5 | func DeterminePathType(path string) string { 6 | // Output Table 7 | if file.Exists(path) { 8 | return "custom" 9 | } 10 | return "default" 11 | } 12 | -------------------------------------------------------------------------------- /pkg/trivy/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package Trivy is a convenience adapter for the Trivy Go SDK. The package facilitates security scanning and report generation of container images. 3 | */ 4 | 5 | package trivy 6 | -------------------------------------------------------------------------------- /pkg/trivy/main.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | tcache "github.com/aquasecurity/trivy/pkg/cache" 9 | "github.com/aquasecurity/trivy/pkg/fanal/analyzer" 10 | "github.com/aquasecurity/trivy/pkg/fanal/artifact" 11 | image2 "github.com/aquasecurity/trivy/pkg/fanal/artifact/image" 12 | "github.com/aquasecurity/trivy/pkg/fanal/image" 13 | ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" 14 | "github.com/aquasecurity/trivy/pkg/result" 15 | "github.com/aquasecurity/trivy/pkg/rpc/client" 16 | "github.com/aquasecurity/trivy/pkg/scanner" 17 | "github.com/aquasecurity/trivy/pkg/types" 18 | v1 "github.com/google/go-containerregistry/pkg/v1" 19 | "github.com/samber/lo" 20 | 21 | dbTypes "github.com/aquasecurity/trivy-db/pkg/types" 22 | 23 | _ "modernc.org/sqlite" // sqlite driver for RPM DB and Java DB 24 | ) 25 | 26 | type ScanOption struct { 27 | DockerHost string 28 | TrivyServer string 29 | Insecure bool 30 | IgnoreUnfixed bool 31 | Architecture *string 32 | } 33 | 34 | func (opts ScanOption) Scan(reference string) (types.Report, error) { 35 | platform := ftypes.Platform{} 36 | if opts.Architecture != nil { 37 | p, _ := v1.ParsePlatform(*opts.Architecture) 38 | platform = ftypes.Platform{ 39 | Platform: p, 40 | } 41 | } 42 | 43 | clientScanner := client.NewScanner(client.ScannerOption{ 44 | RemoteURL: opts.TrivyServer, 45 | Insecure: opts.Insecure, 46 | }, []client.Option(nil)...) 47 | 48 | typesImage, cleanup, err := image.NewContainerImage(context.TODO(), reference, ftypes.ImageOptions{ 49 | RegistryOptions: ftypes.RegistryOptions{ 50 | Insecure: opts.Insecure, 51 | Platform: platform, 52 | }, 53 | DockerOptions: ftypes.DockerOptions{ 54 | Host: opts.DockerHost, 55 | }, 56 | ImageSources: []ftypes.ImageSource{ftypes.RemoteImageSource}, 57 | }) 58 | if err != nil { 59 | slog.Error("NewContainerImage failed", slog.Any("error", err)) 60 | return types.Report{}, err 61 | } 62 | defer cleanup() 63 | 64 | cache := tcache.NewRemoteCache( 65 | tcache.RemoteOptions{ 66 | ServerAddr: opts.TrivyServer, 67 | Insecure: opts.Insecure, 68 | }) 69 | // cache := tcache.NopCache(remoteCache) 70 | 71 | artifactArtifact, err := image2.NewArtifact(typesImage, cache, artifact.Option{ 72 | DisabledAnalyzers: []analyzer.Type{ 73 | analyzer.TypeJar, 74 | analyzer.TypePom, 75 | analyzer.TypeGradleLock, 76 | analyzer.TypeSbtLock, 77 | }, 78 | DisabledHandlers: nil, 79 | FilePatterns: nil, 80 | NoProgress: false, 81 | Insecure: opts.Insecure, 82 | SBOMSources: nil, 83 | RekorURL: "https://rekor.sigstore.dev", 84 | ImageOption: ftypes.ImageOptions{ 85 | RegistryOptions: ftypes.RegistryOptions{ 86 | Insecure: opts.Insecure, 87 | Platform: platform, 88 | }, 89 | DockerOptions: ftypes.DockerOptions{ 90 | Host: opts.DockerHost, 91 | }, 92 | ImageSources: []ftypes.ImageSource{ftypes.RemoteImageSource}, 93 | }, 94 | }) 95 | if err != nil { 96 | slog.Error("NewArtifact failed: %v", slog.Any("error", err)) 97 | return types.Report{}, err 98 | } 99 | 100 | scannerScanner := scanner.NewScanner(clientScanner, artifactArtifact) 101 | report, err := scannerScanner.ScanArtifact(context.TODO(), types.ScanOptions{ 102 | PkgTypes: []string{types.PkgTypeOS}, 103 | Scanners: types.AllScanners, 104 | ImageConfigScanners: types.AllImageConfigScanners, 105 | ScanRemovedPackages: false, 106 | FilePatterns: nil, 107 | IncludeDevDeps: false, 108 | }) 109 | if err != nil { 110 | slog.Error(fmt.Sprintf("ScanArtifact failed: %v", err), slog.Any("report", report)) 111 | return types.Report{}, err 112 | } 113 | 114 | if opts.IgnoreUnfixed { 115 | ignoreStatuses := lo.FilterMap( 116 | dbTypes.Statuses, 117 | func(s string, _ int) (dbTypes.Status, bool) { 118 | fixed := dbTypes.StatusFixed 119 | if s == fixed.String() { 120 | return 0, false 121 | } 122 | return dbTypes.NewStatus(s), true 123 | }, 124 | ) 125 | 126 | result.Filter(context.TODO(), report, result.FilterOptions{ 127 | Severities: []dbTypes.Severity{ 128 | dbTypes.SeverityCritical, 129 | dbTypes.SeverityHigh, 130 | }, 131 | IgnoreStatuses: ignoreStatuses, 132 | }) 133 | } 134 | 135 | return report, nil 136 | } 137 | -------------------------------------------------------------------------------- /pkg/trivy/util.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | import ( 4 | "github.com/aquasecurity/trivy/pkg/types" 5 | ) 6 | 7 | func ContainsOsPkgs(rs types.Results) bool { 8 | for _, r := range rs { 9 | switch r.Class { 10 | case types.ClassOSPkg: 11 | if !r.IsEmpty() { 12 | return true 13 | } 14 | } 15 | } 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /pkg/trivy/util_test.go: -------------------------------------------------------------------------------- 1 | package trivy 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aquasecurity/trivy/pkg/types" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestContainsOsPkgs(t *testing.T) { 11 | // Test case where result contains OS packages 12 | resultsWithOSPkg := types.Results{ 13 | { 14 | Class: types.ClassOSPkg, 15 | Vulnerabilities: []types.DetectedVulnerability{ 16 | {VulnerabilityID: "CVE-2021-1234"}, 17 | }, 18 | }, 19 | } 20 | assert.True(t, ContainsOsPkgs(resultsWithOSPkg)) 21 | 22 | // Test case where result does not contain OS packages 23 | resultsWithoutOSPkg := types.Results{ 24 | { 25 | Class: "SomeOtherClass", 26 | Vulnerabilities: []types.DetectedVulnerability{ 27 | {VulnerabilityID: "CVE-2021-1234"}, 28 | }, 29 | }, 30 | } 31 | assert.False(t, ContainsOsPkgs(resultsWithoutOSPkg)) 32 | 33 | // Test case where result contains OS packages but they are empty 34 | resultsWithEmptyOSPkg := types.Results{ 35 | { 36 | Class: types.ClassOSPkg, 37 | }, 38 | } 39 | assert.False(t, ContainsOsPkgs(resultsWithEmptyOSPkg)) 40 | 41 | // Test case where results are empty 42 | emptyResults := types.Results{} 43 | assert.False(t, ContainsOsPkgs(emptyResults)) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/util/bar/main.go: -------------------------------------------------------------------------------- 1 | package bar 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/k0kubun/go-ansi" 8 | "github.com/schollz/progressbar/v3" 9 | ) 10 | 11 | func New(description string, size int) *progressbar.ProgressBar { 12 | bar := progressbar.NewOptions(size, progressbar.OptionSetWriter(ansi.NewAnsiStdout()), // "github.com/k0kubun/go-ansi" 13 | progressbar.OptionEnableColorCodes(true), 14 | progressbar.OptionShowCount(), 15 | progressbar.OptionOnCompletion(func() { 16 | fmt.Fprint(os.Stderr, "\n") 17 | }), 18 | progressbar.OptionSetWidth(15), 19 | // progressbar.OptionSetRenderBlankState(true), 20 | progressbar.OptionSetDescription(description), 21 | progressbar.OptionShowDescriptionAtLineEnd(), 22 | progressbar.OptionSetTheme(progressbar.Theme{ 23 | Saucer: "[green]=[reset]", 24 | SaucerHead: "[green]>[reset]", 25 | SaucerPadding: " ", 26 | BarStart: "[", 27 | BarEnd: "]", 28 | })) 29 | 30 | return bar 31 | } 32 | -------------------------------------------------------------------------------- /pkg/util/counter/counter.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import "sync" 4 | 5 | // SafeCounter is safe to use concurrently. 6 | type SafeCounter struct { 7 | mu sync.Mutex 8 | v map[string]int 9 | } 10 | 11 | func NewSafeCounter() SafeCounter { 12 | return SafeCounter{v: make(map[string]int)} 13 | } 14 | 15 | // Inc increments the counter for the given key. 16 | func (c *SafeCounter) Inc(key string) { 17 | c.mu.Lock() 18 | // Lock so only one goroutine at a time can access the map c.v. 19 | c.v[key]++ 20 | c.mu.Unlock() 21 | } 22 | 23 | // Value returns the current value of the counter for the given key. 24 | func (c *SafeCounter) Value(key string) int { 25 | c.mu.Lock() 26 | // Lock so only one goroutine at a time can access the map c.v. 27 | defer c.mu.Unlock() 28 | return c.v[key] 29 | } 30 | -------------------------------------------------------------------------------- /pkg/util/counter/counter_test.go: -------------------------------------------------------------------------------- 1 | package counter 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | ) 7 | 8 | func TestSafeCounter(t *testing.T) { 9 | s := NewSafeCounter() 10 | wg := sync.WaitGroup{} 11 | for i := 0; i < 1000; i++ { 12 | wg.Add(1) 13 | go func() { 14 | s.Inc("test") 15 | wg.Done() 16 | }() 17 | } 18 | wg.Wait() 19 | 20 | expected := 1000 21 | 22 | if s.Value("test") != expected { 23 | t.Errorf("want '%d' got '%d'", expected, s.Value("test")) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/util/file/fileop.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "path" 9 | ) 10 | 11 | // write body to file at path 12 | func Write(path string, body []byte) error { 13 | // create the file 14 | f, err := os.Create(path) 15 | if err != nil { 16 | // fmt.Println(err) 17 | return err 18 | } 19 | // close the file with defer 20 | defer f.Close() 21 | 22 | //write directly into file 23 | _, err = f.Write(body) 24 | return err 25 | } 26 | 27 | // copy source to target 28 | func Copy(sourcePath string, targetPath string) error { 29 | source, err := os.Open(sourcePath) 30 | if err != nil { 31 | return err 32 | } 33 | defer source.Close() 34 | 35 | dir, _ := path.Split(targetPath) 36 | err = os.MkdirAll(dir, os.ModePerm) 37 | if err != nil { 38 | return err 39 | } 40 | target, err := os.OpenFile(targetPath, os.O_RDWR|os.O_CREATE, 0666) 41 | if err != nil { 42 | return err 43 | } 44 | defer target.Close() 45 | 46 | _, err = io.Copy(target, source) 47 | if err != nil { 48 | return err 49 | } 50 | return nil 51 | } 52 | 53 | // reads directory returns lines or error 54 | func ReadDir(root string) ([]string, error) { 55 | var files []string 56 | fileInfo, err := os.ReadDir(root) 57 | if err != nil { 58 | return files, err 59 | } 60 | for _, file := range fileInfo { 61 | files = append(files, file.Name()) 62 | } 63 | return files, nil 64 | } 65 | 66 | // path exists? 67 | func Exists(path string) bool { 68 | if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { 69 | // path/to/whatever does not exist 70 | return false 71 | } 72 | return true 73 | } 74 | 75 | func ReadFileAsBytes(path string) ([]byte, error) { 76 | data, err := os.ReadFile(path) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return data, nil 81 | } 82 | 83 | func FileExists(path string) (exists bool) { 84 | _, err := os.Stat(path) 85 | return !errors.Is(err, fs.ErrNotExist) 86 | } 87 | -------------------------------------------------------------------------------- /pkg/util/file/fileop_test.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestWrite(t *testing.T) { 12 | tempDir := t.TempDir() 13 | filePath := filepath.Join(tempDir, "testfile.txt") 14 | body := []byte("Hello, World!") 15 | 16 | err := Write(filePath, body) 17 | assert.NoError(t, err) 18 | 19 | // Verify the file content 20 | content, err := os.ReadFile(filePath) 21 | assert.NoError(t, err) 22 | assert.Equal(t, body, content) 23 | } 24 | 25 | func TestWriteError(t *testing.T) { 26 | err := Write("/invalid/path/testfile.txt", []byte("Hello, World!")) 27 | assert.Error(t, err) 28 | } 29 | 30 | func TestCopy(t *testing.T) { 31 | tempDir := t.TempDir() 32 | sourcePath := filepath.Join(tempDir, "source.txt") 33 | targetPath := filepath.Join(tempDir, "target.txt") 34 | body := []byte("Hello, World!") 35 | 36 | // Write source file 37 | err := os.WriteFile(sourcePath, body, 0644) 38 | assert.NoError(t, err) 39 | 40 | err = Copy(sourcePath, targetPath) 41 | assert.NoError(t, err) 42 | 43 | // Verify the target file content 44 | content, err := os.ReadFile(targetPath) 45 | assert.NoError(t, err) 46 | assert.Equal(t, body, content) 47 | } 48 | 49 | func TestCopyError(t *testing.T) { 50 | err := Copy("/invalid/path/source.txt", "/invalid/path/target.txt") 51 | assert.Error(t, err) 52 | } 53 | 54 | func TestReadDir(t *testing.T) { 55 | tempDir := t.TempDir() 56 | 57 | files := []string{"file1.txt", "file2.txt", "file3.txt"} 58 | for _, f := range files { 59 | err := os.WriteFile(filepath.Join(tempDir, f), []byte("content"), 0644) 60 | assert.NoError(t, err) 61 | } 62 | 63 | result, err := ReadDir(tempDir) 64 | assert.NoError(t, err) 65 | assert.ElementsMatch(t, files, result) 66 | } 67 | 68 | func TestReadDirError(t *testing.T) { 69 | _, err := ReadDir("/invalid/path") 70 | assert.Error(t, err) 71 | } 72 | 73 | func TestExists(t *testing.T) { 74 | tempDir := t.TempDir() 75 | filePath := filepath.Join(tempDir, "testfile.txt") 76 | 77 | // File should not exist initially 78 | assert.False(t, Exists(filePath)) 79 | 80 | // Create the file 81 | err := os.WriteFile(filePath, []byte("content"), 0644) 82 | assert.NoError(t, err) 83 | 84 | // Now the file should exist 85 | assert.True(t, Exists(filePath)) 86 | } 87 | 88 | func TestReadFileAsBytes(t *testing.T) { 89 | tempDir := t.TempDir() 90 | filePath := filepath.Join(tempDir, "testfile.txt") 91 | body := []byte("Hello, World!") 92 | 93 | // Write the file 94 | err := os.WriteFile(filePath, body, 0644) 95 | assert.NoError(t, err) 96 | 97 | content, err := ReadFileAsBytes(filePath) 98 | assert.NoError(t, err) 99 | assert.Equal(t, body, content) 100 | } 101 | 102 | func TestReadFileAsBytesError(t *testing.T) { 103 | _, err := ReadFileAsBytes("/invalid/path/testfile.txt") 104 | assert.Error(t, err) 105 | } 106 | 107 | func TestFileExists(t *testing.T) { 108 | tempDir := t.TempDir() 109 | filePath := filepath.Join(tempDir, "testfile.txt") 110 | 111 | // File should not exist initially 112 | assert.False(t, FileExists(filePath)) 113 | 114 | // Create the file 115 | err := os.WriteFile(filePath, []byte("content"), 0644) 116 | assert.NoError(t, err) 117 | 118 | // Now the file should exist 119 | assert.True(t, FileExists(filePath)) 120 | } 121 | -------------------------------------------------------------------------------- /pkg/util/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | ) 6 | 7 | func GetValue[T any](v *viper.Viper, key string) T { 8 | return v.Get(key).(T) 9 | } 10 | 11 | func SetValue[T any](v *viper.Viper, key string, value T) { 12 | v.Set(key, value) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/util/state/state_test.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/viper" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetValue(t *testing.T) { 11 | v := viper.New() 12 | v.Set("key", "value") 13 | 14 | // Test GetValue for string 15 | result := GetValue[string](v, "key") 16 | assert.Equal(t, "value", result) 17 | 18 | // Test GetValue for int 19 | v.Set("intKey", 42) 20 | intResult := GetValue[int](v, "intKey") 21 | assert.Equal(t, 42, intResult) 22 | } 23 | 24 | func TestSetValue(t *testing.T) { 25 | v := viper.New() 26 | 27 | // Test SetValue for string 28 | SetValue(v, "key", "value") 29 | result := v.GetString("key") 30 | assert.Equal(t, "value", result) 31 | 32 | // Test SetValue for int 33 | SetValue(v, "intKey", 42) 34 | intResult := v.GetInt("intKey") 35 | assert.Equal(t, 42, intResult) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/util/terminal/output.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "sync" 7 | ) 8 | 9 | func CaptureOutput(f func() error) (string, error) { 10 | // Create a pipe 11 | r, w, err := os.Pipe() 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | // Save the original stdout and stderr 17 | origStdout := os.Stdout 18 | origStderr := os.Stderr 19 | defer func() { 20 | os.Stdout = origStdout 21 | os.Stderr = origStderr 22 | }() 23 | 24 | // Redirect stdout and stderr to the pipe 25 | os.Stdout = w 26 | os.Stderr = w 27 | 28 | // Create a buffer to capture the output 29 | var buf bytes.Buffer 30 | wg := sync.WaitGroup{} 31 | wg.Add(1) 32 | 33 | go func() { 34 | defer wg.Done() 35 | _, _ = buf.ReadFrom(r) 36 | }() 37 | 38 | // Call the function and capture the error 39 | err = f() 40 | 41 | // Close the writer and wait for the reader to finish 42 | w.Close() 43 | wg.Wait() 44 | 45 | return buf.String(), err 46 | } 47 | -------------------------------------------------------------------------------- /pkg/util/terminal/output_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCaptureOutput(t *testing.T) { 12 | // Test successful function call 13 | successfulFunc := func() error { 14 | fmt.Println("Hello, World!") 15 | return nil 16 | } 17 | 18 | output, err := CaptureOutput(successfulFunc) 19 | assert.NoError(t, err) 20 | assert.Equal(t, "Hello, World!\n", output) 21 | 22 | // Test function call with error 23 | errorFunc := func() error { 24 | fmt.Println("An error occurred") 25 | return errors.New("error") 26 | } 27 | 28 | output, err = CaptureOutput(errorFunc) 29 | assert.Error(t, err) 30 | assert.Equal(t, "An error occurred\n", output) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/util/terminal/terminal.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/enescakir/emoji" 8 | ) 9 | 10 | // Colored prints 11 | 12 | func PrintGreen(text string) { 13 | colorReset := "\033[0m" 14 | colorGreen := "\033[32m" 15 | 16 | fmt.Printf("%s%s%s\n", string(colorGreen), text, string(colorReset)) 17 | } 18 | 19 | func PrintRed(text string) { 20 | colorReset := "\033[0m" 21 | colorRed := "\033[31m" 22 | 23 | fmt.Printf("%s%s%s\n", string(colorRed), text, string(colorReset)) 24 | } 25 | 26 | func PrintYellow(text string) { 27 | colorReset := "\033[0m" 28 | colorYellow := "\033[33m" 29 | 30 | fmt.Printf("%s%s%s\n", string(colorYellow), text, string(colorReset)) 31 | } 32 | 33 | func LogYellow(text string) { 34 | colorReset := "\033[0m" 35 | colorYellow := "\033[33m" 36 | 37 | log.Printf("%s%s%s\n", string(colorYellow), text, string(colorReset)) 38 | } 39 | 40 | // Emojis 41 | 42 | func GetCheckMarkEmoji() string { 43 | return emoji.CheckMarkButton.String() 44 | } 45 | 46 | func GetWarningEmoji() string { 47 | return emoji.Warning.String() 48 | } 49 | 50 | func GetErrorEmoji() string { 51 | return emoji.CrossMark.String() 52 | } 53 | 54 | func GetDetectiveEmoji() string { 55 | return emoji.Detective.String() 56 | } 57 | 58 | func GetHourglassEmoji() string { 59 | return emoji.HourglassNotDone.String() 60 | } 61 | 62 | func StatusEmoji(b bool) string { 63 | if b { 64 | return GetCheckMarkEmoji() 65 | } 66 | return GetErrorEmoji() 67 | } 68 | -------------------------------------------------------------------------------- /pkg/util/terminal/terminal_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/enescakir/emoji" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestPrintGreen(t *testing.T) { 11 | // No assertion required as we're just printing to console 12 | PrintGreen("Test Green") 13 | } 14 | 15 | func TestPrintRed(t *testing.T) { 16 | // No assertion required as we're just printing to console 17 | PrintRed("Test Red") 18 | } 19 | 20 | func TestPrintYellow(t *testing.T) { 21 | // No assertion required as we're just printing to console 22 | PrintYellow("Test Yellow") 23 | } 24 | 25 | func TestLogYellow(t *testing.T) { 26 | // No assertion required as we're just logging to console 27 | LogYellow("Test Log Yellow") 28 | } 29 | 30 | func TestGetCheckMarkEmoji(t *testing.T) { 31 | e := GetCheckMarkEmoji() 32 | assert.Equal(t, e, emoji.CheckMarkButton.String()) 33 | } 34 | 35 | func TestGetWarningEmoji(t *testing.T) { 36 | e := GetWarningEmoji() 37 | assert.Equal(t, e, emoji.Warning.String()) 38 | } 39 | 40 | func TestGetErrorEmoji(t *testing.T) { 41 | e := GetErrorEmoji() 42 | assert.Equal(t, e, emoji.CrossMark.String()) 43 | } 44 | 45 | func TestGetDetectiveEmoji(t *testing.T) { 46 | e := GetDetectiveEmoji() 47 | assert.Equal(t, e, emoji.Detective.String()) 48 | } 49 | 50 | func TestGetHourglassEmoji(t *testing.T) { 51 | e := GetHourglassEmoji() 52 | assert.Equal(t, e, emoji.HourglassNotDone.String()) 53 | } 54 | 55 | func TestStatusEmoji(t *testing.T) { 56 | checkMark := StatusEmoji(true) 57 | assert.Equal(t, checkMark, GetCheckMarkEmoji()) 58 | 59 | errorMark := StatusEmoji(false) 60 | assert.Equal(t, errorMark, GetErrorEmoji()) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/util/ternary/ternary.go: -------------------------------------------------------------------------------- 1 | package ternary 2 | 3 | // ternary function 4 | func Ternary[T any](cond bool, val1 T, val2 T) T { 5 | if cond { 6 | return val1 7 | } 8 | return val2 9 | } 10 | -------------------------------------------------------------------------------- /pkg/util/ternary/ternary_test.go: -------------------------------------------------------------------------------- 1 | package ternary 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | const ( 8 | TRUE bool = true 9 | FALSE bool = false 10 | ) 11 | 12 | func TestTernaryTrue(t *testing.T) { 13 | expected := TRUE 14 | actual := Ternary(TRUE, TRUE, FALSE) 15 | 16 | if actual != expected { 17 | t.Errorf("want '%t' got '%t'", expected, actual) 18 | } 19 | } 20 | 21 | func TestTernaryFalse(t *testing.T) { 22 | expected := FALSE 23 | actual := Ternary(FALSE, TRUE, FALSE) 24 | 25 | if actual != expected { 26 | t.Errorf("want '%t' got '%t'", expected, actual) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /website/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /website/docs/auth.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: 'Authentication' 3 | sidebar_position: 6 4 | --- 5 | 6 | # Authentication 7 | 8 | ## Helm 9 | 10 | Helmper supports all parameters for defining a Helm Repository. [Read more here](https://helm.sh/docs/helm/helm_repo_add/). 11 | 12 | ```yaml title="Example chart definition" 13 | ... 14 | charts: 15 | - name: prometheus 16 | version: 25.8.0 17 | repo: 18 | name: prometheus-community 19 | url: https://prometheus-community.github.io/helm-charts/ 20 | ... 21 | ``` 22 | 23 | `helmper` will also use the authentication information in the file pointed to by the Helm environment variable `HELM_REGISTRY_CONFIG`. 24 | 25 | Simply login with Helm: 26 | 27 | ```shell title="Example Helm login cmd" 28 | helm registry login [host] [flags] 29 | ``` 30 | 31 | Read mere in the official [Helm Documentation](https://helm.sh/docs/helm/helm_registry_login/). 32 | 33 | ## Registries 34 | 35 | For authenticating against registries, `helmper` utilizes the authentication details present in `~/.docker/config.json`. 36 | 37 | Simply login with Docker or similar commands from your cloud provider: 38 | 39 | ```shell title="Example Docker login cmd" 40 | docker login -u USER -p PASS 41 | ``` 42 | 43 | Read more in the official [Docker Documentation](https://docs.docker.com/reference/cli/docker/login/). 44 | 45 | ### Cloud provider examples 46 | 47 | import Tabs from '@theme/Tabs'; 48 | import TabItem from '@theme/TabItem'; 49 | 50 | 51 | 52 | 53 | ```shell Title "Azure Example" 54 | az acr login -n 55 | ``` 56 | 57 | Read more in [ACR Documentation](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication?tabs=azure-cli). 58 | 59 | 60 | 61 | 62 | 63 | ```shell Title "Amazon Example" 64 | aws ecr get-login-password | docker login -u AWS --password-stdin "https://$(aws sts get-caller-identity --query 'Account' --output text).dkr.ecr.us-east-1.amazonaws.com" 65 | ``` 66 | 67 | Read more in [ECR Documentation](https://docs.aws.amazon.com/AmazonECR/latest/userguide/registry_auth.html). 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /website/docs/ci.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: 'Pipeline Examples' 3 | sidebar_position: 10 4 | --- 5 | 6 | # Pipeline examples 7 | 8 | In this section you can find example pipelines to quickly include Helmper in your pipelines. 9 | 10 | 11 | import Tabs from '@theme/Tabs'; 12 | import TabItem from '@theme/TabItem'; 13 | 14 | 15 | 16 | 17 | ```yaml 18 | trigger: 19 | - main 20 | 21 | pool: 22 | vmImage: 'ubuntu-latest' 23 | 24 | steps: 25 | - task: Bash@3 26 | displayName: 'Install latest Helmper' 27 | inputs: 28 | targetType: 'inline' 29 | script: | 30 | VERSION=$(curl -Lso /dev/null -w %{url_effective} https://github.com/christoffernissen/helmper/releases/latest | grep -o '[^/]*$') 31 | curl -LO https://github.com/christoffernissen/helmper/releases/download/$VERSION/helmper-linux-amd64 32 | chmod +x helmper-linux-amd64 33 | mv helmper-linux-amd64 /usr/local/bin/helmper 34 | 35 | - task: Bash@3 36 | displayName: 'Login registry' 37 | inputs: 38 | targetType: 'inline' 39 | script: | 40 | az acr login -n 41 | 42 | - task: Bash@3 43 | displayName: 'Generate sample configuration' 44 | inputs: 45 | targetType: 'inline' 46 | script: | 47 | cat <>helmper.config 48 | k8s_version: 1.31.1 49 | import: 50 | enabled: true 51 | charts: 52 | - name: prometheus 53 | version: 25.8.0 54 | valuesFilePath: /workspace/in/values/prometheus/values.yaml # (Optional) 55 | repo: 56 | name: prometheus-community 57 | url: https://prometheus-community.github.io/helm-charts/ 58 | registries: 59 | - name: 60 | url: 61 | EOF 62 | 63 | - task: Bash@3 64 | displayName: 'Run Helmper' 65 | inputs: 66 | targetType: 'inline' 67 | script: | 68 | /usr/local/bin/helmper 69 | ``` 70 | 71 | 72 | -------------------------------------------------------------------------------- /website/docs/compatibility.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: 'Compatibility' 3 | sidebar_position: 5 4 | --- 5 | 6 | # Compatibility 7 | 8 | Helmper utilizes the Helm SDK to maintain full compatibility with both Helm Repositories and OCI registries for storing Helm Charts. 9 | 10 | In practice, Helmper currently pushes charts and images to the same destination registry, so it must be OCI compliant. 11 | 12 | Helmper utilizes `oras-go` to push OCI artifacts. Helmper utilizes the Helm SDK to push Helm Charts, as the Helm SDK sets the correct metadata attributes. 13 | 14 | Oras and Helm state support all registries with OCI support, for example: 15 | 16 | - [(Amazon Elastic Container Registry)](https://docs.aws.amazon.com/AmazonECR/latest/userguide/push-oci-artifact.html) 17 | - [Azure Container Registry](https://docs.microsoft.com/azure/container-registry/container-registry-helm-repos#push-chart-to-registry-as-oci-artifact) 18 | - [CNCF Distribution](https://oras.land/docs/compatible_oci_registries#cncf-distribution) - local/offline verification 19 | - [Docker Hub](https://docs.docker.com/docker-hub/oci-artifacts/) 20 | - [Google Artifact Registry](https://cloud.google.com/artifact-registry/docs/helm/manage-charts) 21 | - [GitHub Packages container registry](https://oras.land/docs/compatible_oci_registries#github-packages-container-registry-ghcr) 22 | - [Harbor](https://goharbor.io/docs/main/administration/user-defined-oci-artifact/) 23 | - [JFrog Artifactory](https://jfrog.com/help/r/jfrog-artifactory-documentation/helm-oci-repositories) 24 | - [IBM Cloud Container Registry](https://cloud.ibm.com/docs/Registry?topic=Registry-registry_helm_charts) 25 | - [Zot Registry](https://zotregistry.dev/) 26 | 27 | Sources: [Helm](https://helm.sh/docs/topics/registries/#use-hosted-registries) [Oras](https://oras.land/docs/compatible_oci_registries) 28 | 29 | For testing, Helmper is using the [CNCF Distribution](https://github.com/distribution/distribution) registry. 30 | 31 | :::note 32 | 33 | Amazon Elastic Container Registry (**ECR**) currently has a problem. When pushing new artifacts to ECR, repositories are not created automatically. If the repositories are created up front, Helmper works with ECR. Otherwise you will get a 404 error. 34 | 35 | ::: -------------------------------------------------------------------------------- /website/docs/diagams/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Diagrams", 3 | "position": 9, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Diagrams of Helmper" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /website/docs/diagams/configoptions.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: 'Configuration Options Diagram' 3 | sidebar_position: 3 4 | --- 5 | 6 | # Configuration Options Diagram 7 | 8 | To understand how the different configuration options works, please study the flow diagram below 9 | 10 | ```mermaid 11 | flowchart TD 12 | A[Process Input `helmper.yaml`] --> B(Fetch Charts From Remote) 13 | 14 | B -->|helm pull| B1(Parse Artifacts) 15 | B1 -->|read| B2(Validate Images exists publicly) 16 | 17 | B2 --> C{Import} 18 | C -->|false| End 19 | C -->|true| C1{All} 20 | 21 | C1 --> |false| C2[Identity missing images in registries] 22 | C1 --> |true| G{Patch Images} 23 | 24 | C2 --> G 25 | 26 | G -->|Yes| T1[Trivy Pre Scan] 27 | G -->|No| T6 28 | T1 -->T4{Any `os-pkgs` vulnerabilities} 29 | 30 | T4 -->|Yes| T5[Copacetic] 31 | T4 -->|No| T6[Push] 32 | 33 | T5 --> T7[Trivy Post Scan] 34 | 35 | T7 --> T6 36 | T6 --> H{Sign Images} 37 | H --> End 38 | 39 | End[End] 40 | ``` -------------------------------------------------------------------------------- /website/docs/diagams/er.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: 'ER Diagram' 3 | sidebar_position: 1 4 | --- 5 | 6 | # ER 7 | 8 | In the diagram above it can be seen how the different OCI entities relate. This is the structure that Helmper parses and conceptually handles before considering distributing/patching/signing artifacts. 9 | 10 | Helmper parses Helm Charts from remote registries, identifies enabled dependency charts and analyses all values.yaml files for references to container images. 11 | 12 | ```mermaid 13 | erDiagram 14 | REGISTRY ||--o{ "Helm Chart" : contains 15 | REGISTRY ||--o{ "Container Image (OCI)" : contains 16 | 17 | "Helm Chart" ||--|{ "values.yaml" : has 18 | "Helm Chart" ||--o{ "Dependency Helm Chart" : has 19 | "Dependency Helm Chart" ||--|{ "values.yaml" : has 20 | "values.yaml" ||--|{ "Container Image (OCI)" : references 21 | 22 | "Container Image (OCI)"{ 23 | string Registry 24 | string Repository 25 | string Name 26 | string Tag 27 | } 28 | 29 | "Container Image (OCI)" ||--o| "Signature" : has 30 | "Container Image (OCI)" ||--o| "Digest" : has 31 | ``` 32 | -------------------------------------------------------------------------------- /website/docs/diagams/externalservices.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: 'External Services Diagram' 3 | sidebar_position: 2 4 | --- 5 | 6 | # External services 7 | 8 | This diagram illustrates the external services Helmper communicates with. Helmper will always be interacting with OCI registries and Chart repositories. If you have enabled Copacetic in the configuration, Helmper will also communicate with an external Trivy server and Buildkit daemon. 9 | 10 | ```mermaid 11 | graph LR; 12 | service[helmper]--->|"<.registries[].url>"|pod3[OCI Registry]; 13 | service[helmper]--->|"<.charts[].repo.url>"|pod4[Chart Repository]; 14 | 15 | service[helmper]-..->|<.import.copacetic.trivy.addr>|pod1[Trivy]; 16 | service[helmper]-..->|<.import.copacetic.buildkitd.addr>|pod2[Buildkit]; 17 | ``` 18 | -------------------------------------------------------------------------------- /website/docs/env.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: 'Development Environment' 3 | sidebar_position: 8 4 | --- 5 | 6 | # Development Environment 7 | 8 | The project provides a devcontainer with a docker-compose.yml defining all required services. 9 | 10 | ### Docker 11 | 12 | #### Registry 13 | 14 | ```shell title="bash" 15 | docker run -d -p 5000:5000 --restart=always --name registry registry:2 16 | ``` 17 | 18 | #### Buildkitd 19 | 20 | ```shell title="bash" 21 | export BUILDKIT_VERSION=v0.15.1 22 | export BUILDKIT_PORT=8888 23 | docker run --detach --rm --privileged \ 24 | -p 127.0.0.1:$BUILDKIT_PORT:$BUILDKIT_PORT/tcp \ 25 | --name buildkitd \ 26 | --entrypoint buildkitd \ 27 | "moby/buildkit:$BUILDKIT_VERSION" --addr tcp://0.0.0.0:$BUILDKIT_PORT 28 | ``` 29 | 30 | #### Trivy 31 | 32 | ```shell title="bash" 33 | docker run -d -p 8887:8887 --name trivy \ 34 | aquasec/trivy:0.50.4 server --listen=0.0.0.0:8887 35 | ``` 36 | -------------------------------------------------------------------------------- /website/docs/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_label: 'Install' 3 | sidebar_position: 3 4 | --- 5 | 6 | # Install 7 | 8 | Simply pick the binary for your platform from the Release section on [GitHub](https://github.com/ChristofferNissen/helmper/releases/latest). 9 | 10 | ### Linux 11 | 12 | ```shell title="bash" 13 | VERSION=$(curl -Lso /dev/null -w %{url_effective} https://github.com/christoffernissen/helmper/releases/latest | grep -o '[^/]*$') 14 | curl -LO https://github.com/christoffernissen/helmper/releases/download/$VERSION/helmper-linux-amd64 15 | chmod +x helmper-linux-amd64 16 | sudo mv helmper-linux-amd64 /usr/local/bin/helmper 17 | ``` 18 | 19 | ### Mac OS 20 | 21 | ```shell title="bash" 22 | VERSION=$(curl -Lso /dev/null -w %{url_effective} https://github.com/christoffernissen/helmper/releases/latest | grep -o '[^/]*$') 23 | curl -LO https://github.com/christoffernissen/helmper/releases/download/$VERSION/helmper-darwin-amd64 24 | chmod +x helmper-darwin-amd64 25 | sudo mv helmper-darwin-amd64 /usr/local/bin/helmper 26 | ``` 27 | 28 | ### Windows 29 | 30 | Extract the tar and launch the exe file. 31 | -------------------------------------------------------------------------------- /website/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Intro 6 | 7 | Let's discover **Helmper in less than 5 minutes**. 8 | 9 | ## Getting Started 10 | 11 | Get started by **installing helmper** and **creating a new configuration**. 12 | 13 | ### What you'll need 14 | 15 | - Container Runtime for external services (Registries, Trivy, Buildkit) 16 | - [Podman](https://podman.io/) 17 | - [Docker](https://www.docker.com/) 18 | - Make sure to follow post-install steps to run without root 19 | 20 | ### Install Helmper 21 | 22 | Simply download the latest version of Helmper from GitHub Releases 23 | 24 | #### Linux 25 | 26 | ```shell title="bash" 27 | VERSION=$(curl -Lso /dev/null -w %{url_effective} https://github.com/christoffernissen/helmper/releases/latest | grep -o '[^/]*$') 28 | curl -LO https://github.com/christoffernissen/helmper/releases/download/$VERSION/helmper-linux-amd64 29 | chmod +x helmper-linux-amd64 30 | sudo mv helmper-linux-amd64 /usr/local/bin/helmper 31 | ``` 32 | 33 | ### Configuration 34 | 35 | Create the configuration file 36 | 37 | ```yaml title="$HOME/.config/helmper/helmper.yaml" 38 | k8s_version: 1.31.1 39 | import: 40 | enabled: true 41 | charts: 42 | - name: prometheus 43 | version: 25.8.0 44 | valuesFilePath: /workspace/in/values/prometheus/values.yaml # (Optional) 45 | repo: 46 | name: prometheus-community 47 | url: https://prometheus-community.github.io/helm-charts/ 48 | registries: 49 | - name: registry 50 | url: oci://0.0.0.0:5000 51 | insecure: true 52 | plainHTTP: true 53 | ``` 54 | 55 | ## Start local service 56 | 57 | ### Registry 58 | 59 | ```shell title="bash" 60 | docker run -d -p 5000:5000 --restart=always --name registry registry:2 61 | ``` 62 | 63 | ## Run Helmper 64 | 65 | ```shell title="Run Helmper" 66 | helmper 67 | ``` 68 | 69 |

70 | -------------------------------------------------------------------------------- /website/docs/intro_extended.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Intro Extended 6 | 7 | Let's try all features of **Helmper in less than 5 minutes**. 8 | 9 | In this tutorial demonstrates the full functionality of Helmper, from identifying images 10 | in the Helm Chart to patching and signing the images. 11 | 12 | ## Getting Started 13 | 14 | Get started by **setting up local services**. These services are required for scanning and patching the images. 15 | Then proceed by **creating the local filesystem structure**, populate one of the folders by **generating keys for cosign**. 16 | Finally **change the configuration** to included the newly created resources. 17 | 18 | ### Start local services 19 | 20 | #### Registry 21 | 22 | ```shell title="bash" 23 | docker run -d -p 5000:5000 --restart=always --name registry registry:2 24 | ``` 25 | 26 | #### Buildkitd 27 | 28 | ```shell title="bash" 29 | export BUILDKIT_VERSION=v0.15.1 30 | export BUILDKIT_PORT=8888 31 | docker run --detach --rm --privileged \ 32 | -p 127.0.0.1:$BUILDKIT_PORT:$BUILDKIT_PORT/tcp \ 33 | --name buildkitd --entrypoint buildkitd "moby/buildkit:$BUILDKIT_VERSION" --addr tcp://0.0.0.0:$BUILDKIT_PORT 34 | ``` 35 | 36 | #### Trivy 37 | 38 | ```shell title="bash" 39 | docker run -d -p 8887:8887 --name trivy aquasec/trivy:0.50.4 server --listen=0.0.0.0:8887 40 | ``` 41 | 42 | ### Create output folders 43 | 44 | ```shell title="bash" 45 | mkdir -p $HOME/.config/helmper/out/tars 46 | mkdir -p $HOME/.config/helmper/out/reports 47 | mkdir -p $HOME/.config/helmper/in 48 | ``` 49 | 50 | ### Setup cosign keys 51 | 52 | ```shell title="bash" 53 | docker run -it --name cosign bitnami/cosign generate-key-pair 54 | docker cp cosign:/cosign-keys $HOME/.config/helmper/in/cosign-keys 55 | ``` 56 | 57 | ### Configuration 58 | 59 | Change the configuration file 60 | 61 | :::tip 62 | 63 | Remember to change the user 64 | 65 | ::: 66 | 67 | ```yaml title="$HOME/.config/helmper/helmper.yaml" 68 | k8s_version: 1.31.1 69 | charts: 70 | - name: prometheus 71 | version: 25.8.0 72 | plainHTTP: false 73 | repo: 74 | name: prometheus-community 75 | url: https://prometheus-community.github.io/helm-charts/ 76 | registries: 77 | - name: registry # `Helmper` picks up authentication from the environment automatically. 78 | url: oci://0.0.0.0:5000 79 | insecure: true 80 | plainHTTP: true 81 | import: 82 | enabled: true 83 | copacetic: 84 | enabled: true 85 | ignoreErrors: true 86 | buildkitd: 87 | addr: tcp://0.0.0.0:8888 88 | trivy: 89 | addr: http://0.0.0.0:8887 90 | insecure: true 91 | ignoreUnfixed: true 92 | output: 93 | tars: 94 | folder: /home//.config/helmper/out/tars 95 | clean: true 96 | reports: 97 | folder: /home//.config/helmper/out/reports 98 | clean: true 99 | cosign: 100 | enabled: true 101 | keyRef: /home//.config/helmper/in/cosign-keys/cosign.key 102 | KeyRefPass: "" 103 | allowInsecure: true 104 | allowHTTPRegistry: true 105 | ``` 106 | 107 | ## Run Helmper 108 | 109 | ```shell title="Run Helmper" 110 | helmper 111 | ``` 112 | 113 |

114 | -------------------------------------------------------------------------------- /website/docs/oci.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 11 3 | --- 4 | 5 | # OCI 6 | 7 | Helmper primarily focus on OCI registry compatability, while also supporting Helm Repositories as sources, currently only OCI targets are supported. 8 | 9 | ## Use OCI registry as source 10 | 11 | ```yaml 12 | ... 13 | charts: 14 | - name: kyverno 15 | version: "3.2.*" 16 | plainHTTP: true 17 | repo: 18 | url: oci://0.0.0.0:5000/charts/kyverno 19 | ... 20 | ``` 21 | 22 | *or* use a public provider: 23 | 24 | ```yaml 25 | charts: 26 | - name: cert-manager 27 | version: "1.0.0" 28 | repo: 29 | url: "oci://chartproxy.container-registry.com/charts.jetstack.io/cert-manager" 30 | ``` 31 | 32 | ## Use OCI registry as destination 33 | 34 | Locally you can do 35 | 36 | ```yaml 37 | ... 38 | registries: 39 | - name: registry 40 | url: oci://0.0.0.0:5001 41 | insecure: true 42 | plainHTTP: true 43 | ... 44 | ``` 45 | 46 | *or* use a cloud provider: 47 | 48 | ```yaml 49 | ... 50 | registries: 51 | - name: registry 52 | url: oci://your_registry.azurecr.io 53 | ... 54 | ``` 55 | 56 | Helm Charts will be placed under `charts/{chart_name}`, and images will be placed directly in the regsitry root `/`. 57 | 58 | Optionally, if you specify `registry[].sourcePrefix=true`, images will be placed under a path obtained from the source registry name, fx 59 | 60 | `docker.io/library/hello-world -> oci://reg.azurecr.io/docker/library/hello-world` 61 | 62 | ```yaml 63 | ... 64 | registries: 65 | - name: registry 66 | url: oci://your_registry.azurecr.io 67 | sourcePrefix: true 68 | ... 69 | ``` 70 | -------------------------------------------------------------------------------- /website/docs/parser.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 10 3 | --- 4 | 5 | # Extracting Images from Helm Charts 6 | 7 | On this page you can read about the how `helmper` extracts container image references from Helm Charts `values.yaml` file. 8 | 9 | ## Image Format 10 | 11 | Helmper considers the following elements of an image reference string 12 | 13 | 1. registry 14 | 2. repository 15 | 3. name 16 | 4. tag 17 | 5. digest/sha 18 | 19 | These values will be combined to one of the following strings: 20 | 21 | ```txt 22 | registry/repository/name:tag 23 | ``` 24 | 25 | ```txt 26 | registry/repository/name@digest 27 | ``` 28 | 29 | The combined strings are parsed with [distribution/reference](https://github.com/distribution/reference) library to check image validity. 30 | 31 | ## Supported sections in `values.yaml` 32 | 33 | ```yaml 34 | image: "reference:tag" 35 | ``` 36 | 37 | ```yaml 38 | image: "reference@digest" 39 | ``` 40 | 41 | ```yaml 42 | ... 43 | image: 44 | repository: "docker.io/library/hello-world" 45 | tag: "latest" 46 | ... 47 | ``` 48 | 49 | ```yaml 50 | ... 51 | image: 52 | registry: "docker.io" 53 | repository: "library/hello-world" 54 | tag: "latest" 55 | ... 56 | ``` 57 | 58 | ```yaml 59 | ... 60 | image: 61 | registry: "docker.io" 62 | repository: "library/hello-world" 63 | tag: "latest" 64 | digest: "sha256:266b191e926f65542fa8daaec01a192c4d292bff79426f47300a046e1bc576fd" 65 | useDigest: true 66 | ... 67 | ``` 68 | 69 | **note** *some charts use sha instead of digest. Helmper consider those two to both refer to the digest of the image* 70 | 71 | 84 | 85 | ## Tested against following Helm Charts 86 | 87 | - Prometheus 88 | - Promtail 89 | - Loki 90 | - Mimir-Distributed 91 | - Grafana 92 | - Cilium 93 | - Cert-Manager 94 | - Ingress-Nginx 95 | - Reflector 96 | - Velero 97 | - Kured 98 | - Keda 99 | - Trivy-Operator 100 | - Kubescape-Operator 101 | - ArgoCD 102 | 103 | See more in the [test file](https://github.com/ChristofferNissen/helmper/blob/main/internal/program_test.go) 104 | -------------------------------------------------------------------------------- /website/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import {themes as prismThemes} from 'prism-react-renderer'; 2 | import type {Config} from '@docusaurus/types'; 3 | import type * as Preset from '@docusaurus/preset-classic'; 4 | 5 | const config: Config = { 6 | markdown: { 7 | mermaid: true, 8 | }, 9 | themes: [ 10 | '@docusaurus/theme-mermaid' 11 | ], 12 | 13 | title: 'Helmper Docs', 14 | tagline: 'A little helper that pushes Helm Charts and images to your registries, easily configured with a declarative spec.', 15 | favicon: 'img/favicon.ico', 16 | 17 | // Set the production url of your site here 18 | url: 'https://christoffernissen.github.io', 19 | // Set the // pathname under which your site is served 20 | // For GitHub pages deployment, it is often '//' 21 | baseUrl: '/helmper/', 22 | 23 | trailingSlash: false, 24 | 25 | // GitHub pages deployment config. 26 | // If you aren't using GitHub pages, you don't need these. 27 | organizationName: 'ChristofferNissen', // Usually your GitHub org/user name. 28 | projectName: 'helmper', // Usually your repo name. 29 | 30 | onBrokenLinks: 'throw', 31 | onBrokenMarkdownLinks: 'warn', 32 | 33 | // Even if you don't use internationalization, you can use this field to set 34 | // useful metadata like html lang. For example, if your site is Chinese, you 35 | // may want to replace "en" with "zh-Hans". 36 | i18n: { 37 | defaultLocale: 'en', 38 | locales: ['en'], 39 | }, 40 | 41 | presets: [ 42 | [ 43 | 'classic', 44 | { 45 | docs: { 46 | sidebarPath: './sidebars.ts', 47 | // Please change this to your repo. 48 | // Remove this to remove the "edit this page" links. 49 | // editUrl: 50 | // 'https://github.com/ChristofferNissen/helmper/tree/main/', 51 | }, 52 | blog: { 53 | showReadingTime: true, 54 | // Please change this to your repo. 55 | // Remove this to remove the "edit this page" links. 56 | // editUrl: 57 | // 'https://github.com/ChristofferNissen/helmper/tree/main/', 58 | }, 59 | theme: { 60 | customCss: './src/css/custom.css', 61 | }, 62 | } satisfies Preset.Options, 63 | ], 64 | ], 65 | 66 | themeConfig: { 67 | // Replace with your project's social card 68 | image: 'img/docusaurus-social-card.jpg', 69 | navbar: { 70 | title: 'helmper', 71 | logo: { 72 | alt: 'Helmper Logo', 73 | src: 'img/helmper_logo.svg', 74 | }, 75 | items: [ 76 | { 77 | type: 'docSidebar', 78 | sidebarId: 'docsSidebar', 79 | position: 'left', 80 | label: 'docs', 81 | }, 82 | // {to: '/blog', label: 'Blog', position: 'left'}, 83 | {to: '/what', label: 'what', position: 'left'}, 84 | {to: '/why', label: 'why', position: 'left'}, 85 | {to: '/how', label: 'how', position: 'left'}, 86 | { 87 | href: 'https://github.com/ChristofferNissen/helmper', 88 | label: 'GitHub', 89 | position: 'right', 90 | }, 91 | ], 92 | }, 93 | footer: { 94 | style: 'dark', 95 | links: [ 96 | { 97 | title: 'Docs', 98 | items: [ 99 | { 100 | label: 'Tutorial', 101 | to: '/docs/intro', 102 | }, 103 | { 104 | label: 'Install', 105 | to: '/docs/install', 106 | }, 107 | ], 108 | }, 109 | // { 110 | // title: 'Community', 111 | // items: [ 112 | // // { 113 | // // label: 'Stack Overflow', 114 | // // href: 'https://stackoverflow.com/questions/tagged/docusaurus', 115 | // // }, 116 | // // { 117 | // // label: 'Discord', 118 | // // href: 'https://discordapp.com/invite/docusaurus', 119 | // // }, 120 | // // { 121 | // // label: 'Twitter', 122 | // // href: 'https://twitter.com/docusaurus', 123 | // // }, 124 | // ], 125 | // }, 126 | { 127 | title: 'More', 128 | items: [ 129 | // { 130 | // label: 'Blog', 131 | // to: '/blog', 132 | // }, 133 | { 134 | label: 'GitHub', 135 | href: 'https://github.com/ChristofferNissen/helmper', 136 | }, 137 | ], 138 | }, 139 | ], 140 | copyright: `Copyright © ${new Date().getFullYear()} @ChristofferNissen.`, 141 | }, 142 | prism: { 143 | additionalLanguages: ['bash'], 144 | theme: prismThemes.github, 145 | darkTheme: prismThemes.dracula, 146 | }, 147 | } satisfies Preset.ThemeConfig, 148 | }; 149 | 150 | export default config; 151 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helmper", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^3.4.0", 19 | "@docusaurus/preset-classic": "^3.4.0", 20 | "@docusaurus/theme-mermaid": "^3.4.0", 21 | "@mdx-js/react": "^3.0.0", 22 | "clsx": "^2.0.0", 23 | "prism-react-renderer": "^2.3.0", 24 | "react": "^18.0.0", 25 | "react-dom": "^18.0.0" 26 | }, 27 | "devDependencies": { 28 | "@docusaurus/module-type-aliases": "^3.4.0", 29 | "@docusaurus/tsconfig": "^3.4.0", 30 | "@docusaurus/types": "^3.4.0", 31 | "typescript": "~5.2.2" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.5%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 3 chrome version", 41 | "last 3 firefox version", 42 | "last 5 safari version" 43 | ] 44 | }, 45 | "engines": { 46 | "node": ">=18.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /website/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; 2 | 3 | /** 4 | * Creating a sidebar enables you to: 5 | - create an ordered group of docs 6 | - render a sidebar for each doc of that group 7 | - provide next/previous navigation 8 | 9 | The sidebars can be generated from the filesystem, or explicitly defined here. 10 | 11 | Create as many sidebars as you want. 12 | */ 13 | const sidebars: SidebarsConfig = { 14 | // By default, Docusaurus generates a sidebar from the docs folder structure 15 | docsSidebar: [{type: 'autogenerated', dirName: '.'}], 16 | 17 | // But you can create a sidebar manually 18 | /* 19 | docsSidebar: [ 20 | 'intro', 21 | 'hello', 22 | { 23 | type: 'category', 24 | label: 'Tutorial', 25 | items: ['tutorial-basics/create-a-document'], 26 | }, 27 | ], 28 | */ 29 | }; 30 | 31 | export default sidebars; 32 | -------------------------------------------------------------------------------- /website/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Heading from '@theme/Heading'; 3 | import styles from './styles.module.css'; 4 | 5 | type FeatureItem = { 6 | title: string; 7 | Svg: React.ComponentType>; 8 | description: JSX.Element; 9 | }; 10 | 11 | const FeatureList: FeatureItem[] = [ 12 | { 13 | title: 'Easy to Use', 14 | Svg: require('@site/static/img/timer.svg').default, 15 | description: ( 16 | <> 17 | Helmper was designed from the ground up to be easy to install, 18 | simple to use and robust enough to rely on. 19 | 20 | ), 21 | }, 22 | { 23 | title: 'Built with CNCF projects', 24 | Svg: require('@site/static/img/puzzle.svg').default, 25 | description: ( 26 | <> 27 | Helmper is built using the same tools you would use on the command line. 28 | Helmper is using the Helm Go library. For OCI artifact 29 | distribution Helmper relies on Oras Go library oras-go. 30 | Trivy is used for vulnerability detection and Copacetic is used for vulnerability patching. 31 | 32 | ), 33 | }, 34 | { 35 | title: 'Compatible with most registries', 36 | Svg: require('@site/static/img/api.svg').default, 37 | description: ( 38 | <> 39 | Helmper uses Oras for registry interaction with has support for most registries. 40 | ACR, ECR, GCR, Harbor, Distribution. See more in the docs. 41 | 42 | ), 43 | }, 44 | // { 45 | // title: 'No AI', 46 | // Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, 47 | // description: ( 48 | // <> 49 | // No magic. Helmper relies on boring parsing of Helm Charts and rest/gRPC 50 | // calls to services, so unfortunately Helmper is not using artificial intelligence 51 | // (yet) . 52 | // 53 | // ), 54 | // }, 55 | ]; 56 | 57 | function Feature({title, Svg, description}: FeatureItem) { 58 | return ( 59 |
60 |
61 | 62 |
63 |
64 | {title} 65 |

{description}

66 |
67 |
68 | ); 69 | } 70 | 71 | export default function HomepageFeatures(): JSX.Element { 72 | return ( 73 |
74 |
75 |
76 | {FeatureList.map((props, idx) => ( 77 | 78 | ))} 79 |
80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /website/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /website/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #10584f; 10 | --ifm-color-primary-dark: #0e4f47; 11 | --ifm-color-primary-darker: #0e4b43; 12 | --ifm-color-primary-darkest: #0b3e37; 13 | --ifm-color-primary-light: #126157; 14 | --ifm-color-primary-lighter: #12655b; 15 | --ifm-color-primary-lightest: #157267; 16 | } 17 | 18 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 19 | [data-theme='dark'] { 20 | --ifm-color-primary: #ffc876; 21 | --ifm-color-primary-dark: #ffb951; 22 | --ifm-color-primary-darker: #ffb23e; 23 | --ifm-color-primary-darkest: #ff9b06; 24 | --ifm-color-primary-light: #ffd79b; 25 | --ifm-color-primary-lighter: #ffdeae; 26 | --ifm-color-primary-lightest: #fff5e6; 27 | } 28 | 29 | /* :root { 30 | --ifm-color-primary: #2e8555; 31 | --ifm-color-primary-dark: #29784c; 32 | --ifm-color-primary-darker: #277148; 33 | --ifm-color-primary-darkest: #205d3b; 34 | --ifm-color-primary-light: #33925d; 35 | --ifm-color-primary-lighter: #359962; 36 | --ifm-color-primary-lightest: #3cad6e; 37 | --ifm-code-font-size: 95%; 38 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 39 | } */ 40 | 41 | /* https://docusaurus.io/docs/styling-layout */ -------------------------------------------------------------------------------- /website/src/pages/how.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: How 3 | --- 4 | 5 | # How 6 | 7 | `helmper` is first of all a Helm Chart Analyzer, built for the purpose of addressing a short coming of the metadata attributes in a Helm Chart - missing list of required images needed to deploy the Helm Chart. This is the **core** part of Helmper, and the only part of the functionality that is custom to `helmper`. For the remaining functionality helmper is *standing on the shoulders of giants* to provide additional capabilities right within `helmper`. 8 | 9 | `helmper` is utilizing the following projects: 10 | 11 | * [Helm]() for Helm operations 12 | * [Oras]() for OCI registry interactions 13 | * [Trivy](https://github.com/aquasecurity/trivy) for vulnerability scanning 14 | * [Copacetic](https://github.com/project-copacetic/copacetic) for vulnerability patching 15 | * [Buildkitd](https://github.com/moby/buildkit) container image modification as part of Copacetic 16 | * [Cosign](https://github.com/sigstore/cosign) for container image signing 17 | 18 | `helmper` connects via gRPC to Trivy and Buildkit so you can run `helmper` without root privileges wherever you want - as binary or as container in Kubernetes. 19 | 20 | ## Core 21 | 22 | The diagram below demonstrates the core functionality of Helmper - analyzing Helm Charts and importing the images into OCI-compliant registries. 23 | 24 | ![An image from the static](/img/core.svg) 25 | 26 | 1) Pull Helm Chart(s) from remote registries 27 | 2) Analyse charts for image references 28 | 3) Check status of images in registries 29 | 4) Distribute across registries 30 | 31 | ## Extended 32 | 33 | The diagram below demonstrates the extended functionality of Helmper - extending the core with os level vulnerability scanning, vulnerability patching and signing. 34 | 35 | ![An image from the static](/img/extended.svg) 36 | 37 | 1) Pull Helm Chart(s) 38 | 2) Analyze charts for image references 39 | 3) Check status of images in registries 40 | 4) Pre-patch Scan images with Trivy 41 | 5) Patch images with Copacetic 42 | 6) Post-patch Scan images with Trivy 43 | 7) Push images with `oras-go` 44 | 8) Sign images with Cosign 45 | -------------------------------------------------------------------------------- /website/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | 25 | .features { 26 | display: flex; 27 | justify-content: center; 28 | /* align-items: center; */ 29 | padding: 2rem 0; 30 | width: 100%; 31 | height: auto; 32 | max-height: 500px; 33 | } -------------------------------------------------------------------------------- /website/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Link from '@docusaurus/Link'; 3 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 4 | import Layout from '@theme/Layout'; 5 | import HomepageFeatures from '@site/src/components/HomepageFeatures'; 6 | import Heading from '@theme/Heading'; 7 | 8 | import styles from './index.module.css'; 9 | 10 | function HomepageHeader() { 11 | const {siteConfig} = useDocusaurusContext(); 12 | const Logo = require('@site/static/img/helmper_logo.svg').default 13 | return ( 14 |
15 |
16 | 17 | 18 | {siteConfig.title} 19 | 20 |

{siteConfig.tagline}

21 |
22 | 25 | Get Started - 5min ⏱️ 26 | 27 |
28 |
29 |
30 | ); 31 | } 32 | 33 | export default function Home(): JSX.Element { 34 | const {siteConfig} = useDocusaurusContext(); 35 | return ( 36 | 39 | 40 |
41 | 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /website/src/pages/what.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: What 3 | --- 4 | 5 | # What 6 | 7 | 8 | 9 | `helmper` is a Helm Chart analyzer that reads Helm Charts from remote OCI registries and pushes the charts container images to your registries with optional OS level vulnerability patching. 10 | 11 | `helmper` provides an interface to reduce the maintenance burden associated with managing a large collection of Helm Charts by: 12 | 13 | - automatically detecting all enabled container images in charts by examine the charts values 14 | - providing an easy way to stay up to date on new chart releases through a repeatable and fast process 15 | - providing option to only import new images - or all images 16 | - enabling quick patching of OS level vulnerabilities in container images 17 | - enabling signing of images as an integrated part of the process 18 | - providing a mechanism to check dependencies are met before deploying charts with fx GitOps 19 | 20 | `helmper` is built with [Helm](), [Oras](), [Trivy](https://github.com/aquasecurity/trivy), [Copacetic](https://github.com/project-copacetic/copacetic) ([Buildkit](https://github.com/moby/buildkit)) and [Cosign](https://github.com/sigstore/cosign). 21 | -------------------------------------------------------------------------------- /website/src/pages/why.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Why 3 | --- 4 | 5 | # Why 6 | 7 | `helmper` demonstrates exceptional proficiency in operating within controlled environments that might require Change Management and/or air-gapped networks. This expertise is particularly beneficial in industries subject to stringent regulations, such as Medical and Banking. 8 | 9 | ## Security 10 | 11 | `helmper` aims to provide a sane opt-in security process by scanning images for vulnerabilities and patching fixable OS level vulnerabilities in container images retrieved from public sources, before distribution to your registries. 12 | 13 | ## Operations 14 | 15 | `helmper` aims to ensure binary reproducibility of Helm Charts by storing all necessary artifacts in your registries, reducing risk associated with disappearing upstream dependencies. 16 | 17 | `helmper` aims to reduce maintenance efforts of onboarding new Helm Chart versions to use within an regulated organization by providing a robust engine for extracting images from charts, patching and distributing images in a fast, reliable and repeatable way. 18 | 19 | *In most industries it might be acceptable to use registries as pull-through caches that will automatically find missing images and store them in your registries. For regulated industries this presents a challenge, as it is not worth the risk to start a deployment to production and wait for resources to be fetched.* 20 | -------------------------------------------------------------------------------- /website/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChristofferNissen/helmper/2d2493276edb74dda8d456d66eae4c8c871f8ccd/website/static/.nojekyll -------------------------------------------------------------------------------- /website/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChristofferNissen/helmper/2d2493276edb74dda8d456d66eae4c8c871f8ccd/website/static/img/favicon.ico -------------------------------------------------------------------------------- /website/static/img/puzzle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | --------------------------------------------------------------------------------