diff --git a/.github/actions/slack-notify/action.yml b/.github/actions/slack-notify/action.yml new file mode 100644 index 000000000..59d9db9b0 --- /dev/null +++ b/.github/actions/slack-notify/action.yml @@ -0,0 +1,31 @@ +# Copyright (c) Codesphere Inc. +# SPDX-License-Identifier: Apache-2.0 + +# Posts a failure notification to the #team-oms-sdk Slack channel. +name: Slack Notification on Failure +description: Notify #team-oms-sdk on job failure + +inputs: + job-name: + description: Display name of the failing job (e.g. "Build", "Test", "Install-Config") + required: true + webhook: + description: Slack webhook URL for the notification + required: true + +runs: + using: composite + steps: + - name: Post failure to Slack + shell: bash + env: + SLACK_WEBHOOK: ${{ inputs.webhook }} + run: | + curl -X POST \ + -H 'Content-type: application/json' \ + --data "{ + \"channel\": \"#team-oms-sdk\", + \"icon_emoji\": \":siren:\", + \"text\": \"🚨 ${{ inputs.job-name }} Job Failure 🚨\nRepository: ${{ github.repository }}\nBranch/Ref: \`${{ github.ref }}\`\nRun url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\" + }" \ + "${SLACK_WEBHOOK}" diff --git a/.github/workflows/cli-build_test.yml b/.github/workflows/cli-build_test.yml index ab3dfbe4d..05d7a7176 100644 --- a/.github/workflows/cli-build_test.yml +++ b/.github/workflows/cli-build_test.yml @@ -14,52 +14,72 @@ on: jobs: - build: + build-test: + name: ${{ matrix.label }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - task: build + label: build + - task: test + label: test + - task: install-config + label: install-config steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: 'go.mod' - - name: Build + - name: Build CLI + if: ${{ matrix.task == 'build' || matrix.task == 'install-config' }} run: make build-cli - - name: Slack Notification on Failure - if: ${{ failure() && github.ref == 'refs/heads/main' }} - run: | - curl -X POST \ - -H 'Content-type: application/json' \ - --data '{ - "channel": "#team-oms-sdk", - "icon_emoji": ":siren:", - "text": "🚨 Build Job Failure 🚨\nRepository: ${{ github.repository }}\nBranch/Ref: `${{ github.ref }}`\nRun url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - }' \ - ${{ secrets.PACKAGE_JOB_SLACK_WEBHOOK }} + - name: Test + if: ${{ matrix.task == 'test' }} + run: make test - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Setup SOPS and age for vault encryption + if: ${{ matrix.task == 'install-config' }} + run: | + sudo apt-get update + sudo apt-get install -y age + # sops is not packaged in Ubuntu's repos, so fetch the pinned release binary. + curl -fsSL -o /tmp/sops \ + https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.linux.amd64 + sudo install -m 0755 /tmp/sops /usr/local/bin/sops + age-keygen -o age_key.txt + echo "SOPS_AGE_KEY_FILE=${GITHUB_WORKSPACE}/age_key.txt" >> "$GITHUB_ENV" - - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 - with: - go-version-file: 'go.mod' + - name: Generate and validate install configs + if: ${{ matrix.task == 'install-config' }} + run: | + set -euo pipefail + for profile in dev minimal production; do + echo "==> Generating install config for profile: ${profile}" + ./oms init install-config \ + --profile "${profile}" \ + --interactive=false \ + -c "config-${profile}.yaml" \ + --vault "prod-${profile}.vault.yaml" - - name: Test - run: make test + echo "==> Validating generated install config for profile: ${profile}" + # Validate the config only. The generated vault is SOPS-encrypted and + # its contents are covered by the round-trip unit tests, so this step + # stays focused on the config (--vault "" skips vault loading). + ./oms init install-config \ + --validate \ + -c "config-${profile}.yaml" \ + --vault "" + done - name: Slack Notification on Failure if: ${{ failure() && github.ref == 'refs/heads/main' }} - run: | - curl -X POST \ - -H 'Content-type: application/json' \ - --data '{ - "channel": "#team-oms-sdk", - "icon_emoji": ":siren:", - "text": "🚨 Build Job Failure 🚨\nRepository: ${{ github.repository }}\nBranch/Ref: `${{ github.ref }}`\nRun url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - }' \ - ${{ secrets.PACKAGE_JOB_SLACK_WEBHOOK }} + uses: ./.github/actions/slack-notify + with: + job-name: ${{ matrix.label }} + webhook: ${{ secrets.PACKAGE_JOB_SLACK_WEBHOOK }} diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index 65558e92b..535ba94d0 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -17,11 +17,11 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: 'go.mod' diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 9cc5044e1..e32a02c53 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -13,10 +13,10 @@ jobs: integration-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: 'go.mod' diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 02babe450..f50289713 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -21,13 +21,13 @@ jobs: runs-on: ubuntu-latest needs: integration-tests steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-tags: true fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: 'go.mod' diff --git a/.github/workflows/update-docs-and-licenses.yml b/.github/workflows/update-docs-and-licenses.yml index 37b68b678..d67552668 100644 --- a/.github/workflows/update-docs-and-licenses.yml +++ b/.github/workflows/update-docs-and-licenses.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # on push to main, use main; on PR, check out the PR head ref: ${{ github.event.pull_request.head.ref || github.ref }} @@ -25,7 +25,7 @@ jobs: token: ${{ secrets.PAT_UPDATE_DOCS }} - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: 'go.mod' @@ -34,7 +34,7 @@ jobs: ./hack/update-docs-and-licenses.sh - name: Commit and push auto-generated changes - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9 + uses: EndBug/add-and-commit@cc9c08ba6c8df3b93a8f2db63e89b98368ae2ae8 # v11 with: # Use the PR author's identity for the commit author_name: ${{ github.event.pull_request.user.login }} diff --git a/.gitignore b/.gitignore index eb2c4d4bf..fc1352a33 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ vendor .envrc .env .installer +tmp # bin file bin/ diff --git a/Makefile b/Makefile index c74ed3210..f0a1c2c5a 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ test: # -count=1 to disable caching test results go test -count=1 -v ./... -test-integration: +test-integration: build-cli # Run integration tests with build tag go test -count=1 -v -tags=integration ./cli/... diff --git a/NOTICE b/NOTICE index e95f889cf..963b1e832 100644 --- a/NOTICE +++ b/NOTICE @@ -11,9 +11,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry ---------- Module: cloud.google.com/go/auth -Version: v0.23.0 +Version: v0.23.2 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/auth/v0.23.0/auth/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/auth/v0.23.2/auth/LICENSE ---------- Module: cloud.google.com/go/auth/oauth2adapt @@ -23,9 +23,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/auth/oauth2adapt ---------- Module: cloud.google.com/go/compute -Version: v1.66.0 +Version: v1.68.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/compute/v1.66.0/compute/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/compute/v1.68.0/compute/LICENSE ---------- Module: cloud.google.com/go/compute/metadata @@ -71,9 +71,9 @@ License URL: https://github.com/imdario/mergo/blob/v1.0.2/LICENSE ---------- Module: filippo.io/age -Version: v1.3.1 +Version: v1.3.2 License: BSD-3-Clause -License URL: https://github.com/FiloSottile/age/blob/v1.3.1/LICENSE +License URL: https://github.com/FiloSottile/age/blob/v1.3.2/LICENSE ---------- Module: filippo.io/edwards25519 @@ -167,9 +167,9 @@ License URL: https://github.com/argoproj/argo-cd/blob/7660efb23b2d/gitops-engine ---------- Module: github.com/argoproj/argo-cd/v3 -Version: v3.5.0 +Version: v3.5.3 License: Apache-2.0 -License URL: https://github.com/argoproj/argo-cd/blob/v3.5.0/LICENSE +License URL: https://github.com/argoproj/argo-cd/blob/v3.5.3/LICENSE ---------- Module: github.com/argoproj/pkg/v2 @@ -299,9 +299,9 @@ License URL: https://github.com/cloudnative-pg/machinery/blob/v0.5.0/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go -Version: v1.20.0 +Version: v1.40.0 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v1.20.0/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v1.40.0/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl @@ -329,15 +329,15 @@ License URL: https://github.com/creativeprojects/go-selfupdate/blob/v1.6.0/LICEN ---------- Module: github.com/cyphar/filepath-securejoin -Version: v0.6.1 +Version: v0.7.0 License: MPL-2.0 -License URL: https://github.com/cyphar/filepath-securejoin/blob/v0.6.1/COPYING.md +License URL: https://github.com/cyphar/filepath-securejoin/blob/v0.7.0/COPYING.md ---------- Module: github.com/cyphar/filepath-securejoin -Version: v0.6.1 +Version: v0.7.0 License: BSD-3-Clause -License URL: https://github.com/cyphar/filepath-securejoin/blob/v0.6.1/COPYING.md +License URL: https://github.com/cyphar/filepath-securejoin/blob/v0.7.0/COPYING.md ---------- Module: github.com/davecgh/go-spew/spew @@ -357,6 +357,18 @@ Version: v1.12.0 License: MIT License URL: https://github.com/dlclark/regexp2/blob/v1.12.0/LICENSE +---------- +Module: github.com/docker/cli/cli/config +Version: v29.7.2 +License: Apache-2.0 +License URL: https://github.com/docker/cli/blob/v29.7.2/LICENSE + +---------- +Module: github.com/docker/docker-credential-helpers +Version: v0.9.8 +License: MIT +License URL: https://github.com/docker/docker-credential-helpers/blob/v0.9.8/LICENSE + ---------- Module: github.com/dylibso/observe-sdk/go Version: v0.0.0-20240828172851-9145d8ad07e1 @@ -419,9 +431,9 @@ License URL: https://github.com/felixge/httpsnoop/blob/v1.1.0/LICENSE.txt ---------- Module: github.com/fluxcd/cli-utils/pkg -Version: v1.2.1 +Version: v1.2.2 License: Apache-2.0 -License URL: https://github.com/fluxcd/cli-utils/blob/v1.2.1/LICENSE +License URL: https://github.com/fluxcd/cli-utils/blob/v1.2.2/LICENSE ---------- Module: github.com/fsnotify/fsnotify @@ -515,87 +527,87 @@ License URL: https://github.com/go-logr/zapr/blob/v1.3.0/LICENSE ---------- Module: github.com/go-openapi/jsonpointer -Version: v0.23.1 +Version: v1.0.0 License: Apache-2.0 -License URL: https://github.com/go-openapi/jsonpointer/blob/v0.23.1/LICENSE +License URL: https://github.com/go-openapi/jsonpointer/blob/v1.0.0/LICENSE ---------- Module: github.com/go-openapi/jsonreference -Version: v0.21.6 +Version: v1.0.0 License: Apache-2.0 -License URL: https://github.com/go-openapi/jsonreference/blob/v0.21.6/LICENSE +License URL: https://github.com/go-openapi/jsonreference/blob/v1.0.0/LICENSE ---------- Module: github.com/go-openapi/swag -Version: v0.26.1 +Version: v0.27.1 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/v0.26.1/LICENSE +License URL: https://github.com/go-openapi/swag/blob/v0.27.1/LICENSE ---------- Module: github.com/go-openapi/swag/cmdutils -Version: v0.26.1 +Version: v0.27.1 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/cmdutils/v0.26.1/cmdutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/cmdutils/v0.27.1/cmdutils/LICENSE ---------- Module: github.com/go-openapi/swag/conv -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/conv/v0.26.1/conv/LICENSE +License URL: https://github.com/go-openapi/swag/blob/conv/v0.27.3/conv/LICENSE ---------- Module: github.com/go-openapi/swag/fileutils -Version: v0.26.1 -License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/fileutils/v0.26.1/fileutils/LICENSE - ----------- -Module: github.com/go-openapi/swag/jsonname -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/jsonname/v0.26.1/jsonname/LICENSE +License URL: https://github.com/go-openapi/swag/blob/fileutils/v0.27.3/fileutils/LICENSE ---------- Module: github.com/go-openapi/swag/jsonutils -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/jsonutils/v0.26.1/jsonutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/jsonutils/v0.27.3/jsonutils/LICENSE ---------- Module: github.com/go-openapi/swag/loading -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/loading/v0.26.1/loading/LICENSE +License URL: https://github.com/go-openapi/swag/blob/loading/v0.27.3/loading/LICENSE ---------- Module: github.com/go-openapi/swag/mangling -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/mangling/v0.26.1/mangling/LICENSE +License URL: https://github.com/go-openapi/swag/blob/mangling/v0.27.3/mangling/LICENSE ---------- Module: github.com/go-openapi/swag/netutils -Version: v0.26.1 +Version: v0.27.1 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/netutils/v0.26.1/netutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/netutils/v0.27.1/netutils/LICENSE + +---------- +Module: github.com/go-openapi/swag/pools +Version: v0.27.3 +License: Apache-2.0 +License URL: https://github.com/go-openapi/swag/blob/pools/v0.27.3/pools/LICENSE ---------- Module: github.com/go-openapi/swag/stringutils -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/stringutils/v0.26.1/stringutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/stringutils/v0.27.3/stringutils/LICENSE ---------- Module: github.com/go-openapi/swag/typeutils -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/typeutils/v0.26.1/typeutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/typeutils/v0.27.3/typeutils/LICENSE ---------- Module: github.com/go-openapi/swag/yamlutils -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/yamlutils/v0.26.1/yamlutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/yamlutils/v0.27.3/yamlutils/LICENSE ---------- Module: github.com/go-redis/cache/v9 @@ -651,18 +663,18 @@ Version: v0.7.0 License: BSD-3-Clause License URL: https://github.com/google/go-cmp/blob/v0.7.0/LICENSE +---------- +Module: github.com/google/go-containerregistry +Version: v0.22.1 +License: Apache-2.0 +License URL: https://github.com/google/go-containerregistry/blob/v0.22.1/LICENSE + ---------- Module: github.com/google/go-github/v69/github Version: v69.2.0 License: BSD-3-Clause License URL: https://github.com/google/go-github/blob/v69.2.0/LICENSE ----------- -Module: github.com/google/go-github/v74/github -Version: v74.0.0 -License: BSD-3-Clause -License URL: https://github.com/google/go-github/blob/v74.0.0/LICENSE - ---------- Module: github.com/google/go-github/v86/github Version: v86.0.0 @@ -707,9 +719,9 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. ---------- Module: github.com/googleapis/gax-go/v2 -Version: v2.23.0 +Version: v2.24.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.23.0/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.24.0/v2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -868,28 +880,40 @@ License: MIT License URL: https://github.com/kevinburke/ssh_config/blob/v1.6.0/LICENSE ---------- -Module: github.com/klauspost/compress/internal -Version: v1.19.1 +Module: github.com/klauspost/compress +Version: v1.19.2 License: MIT -License URL: https://github.com/klauspost/compress/blob/v1.19.1/LICENSE +License URL: https://github.com/klauspost/compress/blob/v1.19.2/LICENSE ---------- -Module: github.com/klauspost/compress/internal -Version: v1.19.1 +Module: github.com/klauspost/compress +Version: v1.19.2 License: Apache-2.0 -License URL: https://github.com/klauspost/compress/blob/v1.19.1/LICENSE +License URL: https://github.com/klauspost/compress/blob/v1.19.2/LICENSE + +---------- +Module: github.com/klauspost/compress +Version: v1.19.2 +License: BSD-3-Clause +License URL: https://github.com/klauspost/compress/blob/v1.19.2/LICENSE ---------- -Module: github.com/klauspost/compress/internal -Version: v1.19.1 +Module: github.com/klauspost/compress/internal/snapref +Version: v1.19.2 License: BSD-3-Clause -License URL: https://github.com/klauspost/compress/blob/v1.19.1/LICENSE +License URL: https://github.com/klauspost/compress/blob/v1.19.2/internal/snapref/LICENSE ---------- Module: github.com/klauspost/compress/s2 -Version: v1.19.1 +Version: v1.19.2 License: BSD-3-Clause -License URL: https://github.com/klauspost/compress/blob/v1.19.1/s2/LICENSE +License URL: https://github.com/klauspost/compress/blob/v1.19.2/s2/LICENSE + +---------- +Module: github.com/klauspost/compress/zstd/internal/xxhash +Version: v1.19.2 +License: MIT +License URL: https://github.com/klauspost/compress/blob/v1.19.2/zstd/internal/xxhash/LICENSE.txt ---------- Module: github.com/klauspost/cpuid/v2 @@ -905,9 +929,9 @@ License URL: https://github.com/kr/fs/blob/v0.1.0/LICENSE ---------- Module: github.com/kube-object-storage/lib-bucket-provisioner/pkg/apis/objectbucket.io -Version: v0.0.0-20221122204822-d1a8c34382f1 +Version: v0.0.0-20260420161730-5164e3746489 License: Apache-2.0 -License URL: https://github.com/kube-object-storage/lib-bucket-provisioner/blob/d1a8c34382f1/LICENSE +License URL: https://github.com/kube-object-storage/lib-bucket-provisioner/blob/5164e3746489/LICENSE ---------- Module: github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1 @@ -1043,9 +1067,9 @@ License URL: https://github.com/munnerz/goautoneg/blob/a7dc8b61c822/LICENSE ---------- Module: github.com/onsi/gomega -Version: v1.42.1 +Version: v1.43.0 License: MIT -License URL: https://github.com/onsi/gomega/blob/v1.42.1/LICENSE +License URL: https://github.com/onsi/gomega/blob/v1.43.0/LICENSE ---------- Module: github.com/opencontainers/go-digest @@ -1157,9 +1181,9 @@ License URL: https://github.com/robfig/cron/blob/bc59245fe10e/LICENSE ---------- Module: github.com/rook/rook/pkg/apis/ceph.rook.io -Version: v0.0.0-20260811210519-a4c28bc84816 +Version: v0.0.0-20260915091545-fb2d518f653f License: Apache-2.0 -License URL: https://github.com/rook/rook/blob/a4c28bc84816/pkg/apis/LICENSE +License URL: https://github.com/rook/rook/blob/fb2d518f653f/pkg/apis/LICENSE ---------- Module: github.com/rubenv/sql-migrate @@ -1187,9 +1211,9 @@ License URL: https://github.com/ryanuber/go-glob/blob/v1.0.0/LICENSE ---------- Module: github.com/santhosh-tekuri/jsonschema/v6 -Version: v6.0.2 +Version: v6.0.3 License: Apache-2.0 -License URL: https://github.com/santhosh-tekuri/jsonschema/blob/v6.0.2/LICENSE +License URL: https://github.com/santhosh-tekuri/jsonschema/blob/v6.0.3/LICENSE ---------- Module: github.com/sergi/go-diff/diffmatchpatch @@ -1205,9 +1229,9 @@ License URL: https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE ---------- Module: github.com/sirupsen/logrus -Version: v1.9.4 +Version: v1.10.1 License: MIT -License URL: https://github.com/sirupsen/logrus/blob/v1.9.4/LICENSE +License URL: https://github.com/sirupsen/logrus/blob/v1.10.1/LICENSE ---------- Module: github.com/skeema/knownhosts @@ -1241,9 +1265,21 @@ License URL: https://github.com/stretchr/objx/blob/v0.5.3/LICENSE ---------- Module: github.com/stretchr/testify -Version: v1.11.1 +Version: v1.12.1 License: MIT -License URL: https://github.com/stretchr/testify/blob/v1.11.1/LICENSE +License URL: https://github.com/stretchr/testify/blob/v1.12.1/LICENSE + +---------- +Module: github.com/stretchr/testify/internal/difflib +Version: v1.12.1 +License: BSD-3-Clause +License URL: https://github.com/stretchr/testify/blob/v1.12.1/internal/difflib/LICENSE + +---------- +Module: github.com/stretchr/testify/internal/spew +Version: v1.12.1 +License: ISC +License URL: https://github.com/stretchr/testify/blob/v1.12.1/internal/spew/LICENSE ---------- Module: github.com/tetratelabs/wabin @@ -1415,51 +1451,51 @@ License URL: https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE ---------- Module: golang.org/x/crypto -Version: v0.55.0 +Version: v0.57.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/crypto/+/v0.55.0:LICENSE +License URL: https://cs.opensource.google/go/x/crypto/+/v0.57.0:LICENSE ---------- Module: golang.org/x/mod/semver -Version: v0.39.0 +Version: v0.41.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/mod/+/v0.39.0:LICENSE +License URL: https://cs.opensource.google/go/x/mod/+/v0.41.0:LICENSE ---------- Module: golang.org/x/net -Version: v0.57.0 +Version: v0.58.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/net/+/v0.57.0:LICENSE +License URL: https://cs.opensource.google/go/x/net/+/v0.58.0:LICENSE ---------- Module: golang.org/x/oauth2 -Version: v0.36.0 +Version: v0.37.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE +License URL: https://cs.opensource.google/go/x/oauth2/+/v0.37.0:LICENSE ---------- Module: golang.org/x/sync -Version: v0.22.0 +Version: v0.23.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sync/+/v0.22.0:LICENSE +License URL: https://cs.opensource.google/go/x/sync/+/v0.23.0:LICENSE ---------- Module: golang.org/x/sys -Version: v0.47.0 +Version: v0.48.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sys/+/v0.47.0:LICENSE +License URL: https://cs.opensource.google/go/x/sys/+/v0.48.0:LICENSE ---------- Module: golang.org/x/term -Version: v0.45.0 +Version: v0.46.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/term/+/v0.45.0:LICENSE +License URL: https://cs.opensource.google/go/x/term/+/v0.46.0:LICENSE ---------- Module: golang.org/x/text -Version: v0.41.0 +Version: v0.42.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/text/+/v0.41.0:LICENSE +License URL: https://cs.opensource.google/go/x/text/+/v0.42.0:LICENSE ---------- Module: golang.org/x/time/rate @@ -1475,15 +1511,15 @@ License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api -Version: v0.293.0 +Version: v0.298.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.293.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.298.0/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.293.0 +Version: v0.298.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.293.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.298.0/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis @@ -1499,15 +1535,15 @@ License URL: https://github.com/googleapis/go-genproto/blob/e059f2f05d78/googlea ---------- Module: google.golang.org/genproto/googleapis/rpc -Version: v0.0.0-20260807164820-c8921c73eeea +Version: v0.0.0-20260819154853-08b0e4226688 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/c8921c73eeea/googleapis/rpc/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/08b0e4226688/googleapis/rpc/LICENSE ---------- Module: google.golang.org/grpc -Version: v1.83.0 +Version: v1.83.2 License: Apache-2.0 -License URL: https://github.com/grpc/grpc-go/blob/v1.83.0/LICENSE +License URL: https://github.com/grpc/grpc-go/blob/v1.83.2/LICENSE ---------- Module: google.golang.org/protobuf @@ -1553,81 +1589,81 @@ License URL: https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE ---------- Module: helm.sh/helm/v4 -Version: v4.2.3 +Version: v4.3.0 License: Apache-2.0 -License URL: https://github.com/helm/helm/blob/v4.2.3/LICENSE +License URL: https://github.com/helm/helm/blob/v4.3.0/LICENSE ---------- Module: k8s.io/api -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/api/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/api/blob/v0.36.4/LICENSE ---------- Module: k8s.io/apiextensions-apiserver/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/apiextensions-apiserver/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/apiextensions-apiserver/blob/v0.36.4/LICENSE ---------- Module: k8s.io/apimachinery/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/apimachinery/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/apimachinery/blob/v0.36.4/LICENSE ---------- Module: k8s.io/apimachinery/third_party/forked/golang -Version: v0.36.3 +Version: v0.36.4 License: BSD-3-Clause -License URL: https://github.com/kubernetes/apimachinery/blob/v0.36.3/third_party/forked/golang/LICENSE +License URL: https://github.com/kubernetes/apimachinery/blob/v0.36.4/third_party/forked/golang/LICENSE ---------- Module: k8s.io/apiserver/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/apiserver/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/apiserver/blob/v0.36.4/LICENSE ---------- Module: k8s.io/cli-runtime/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/cli-runtime/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/cli-runtime/blob/v0.36.4/LICENSE ---------- Module: k8s.io/client-go -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/client-go/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.36.4/LICENSE ---------- Module: k8s.io/client-go/third_party/forked/golang/template -Version: v0.36.3 +Version: v0.36.4 License: BSD-3-Clause -License URL: https://github.com/kubernetes/client-go/blob/v0.36.3/third_party/forked/golang/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.36.4/third_party/forked/golang/LICENSE ---------- Module: k8s.io/client-go/third_party/forked/httpcache -Version: v0.36.3 +Version: v0.36.4 License: MIT -License URL: https://github.com/kubernetes/client-go/blob/v0.36.3/third_party/forked/httpcache/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.36.4/third_party/forked/httpcache/LICENSE ---------- Module: k8s.io/component-base -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/component-base/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/component-base/blob/v0.36.4/LICENSE ---------- Module: k8s.io/component-helpers -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/component-helpers/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/component-helpers/blob/v0.36.4/LICENSE ---------- Module: k8s.io/controller-manager/pkg/features -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/controller-manager/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/controller-manager/blob/v0.36.4/LICENSE ---------- Module: k8s.io/klog/v2 @@ -1637,45 +1673,45 @@ License URL: https://github.com/kubernetes/klog/blob/v2.140.0/LICENSE ---------- Module: k8s.io/kube-aggregator/pkg/apis/apiregistration -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/kube-aggregator/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/kube-aggregator/blob/v0.36.4/LICENSE ---------- Module: k8s.io/kube-openapi/pkg -Version: v0.0.0-20260603220949-865597e52e25 +Version: v0.0.0-20260721132016-d427ff9ee9ad License: Apache-2.0 -License URL: https://github.com/kubernetes/kube-openapi/blob/865597e52e25/LICENSE +License URL: https://github.com/kubernetes/kube-openapi/blob/d427ff9ee9ad/LICENSE ---------- Module: k8s.io/kube-openapi/pkg/internal/third_party/go-json-experiment/json -Version: v0.0.0-20260603220949-865597e52e25 +Version: v0.0.0-20260721132016-d427ff9ee9ad License: BSD-3-Clause -License URL: https://github.com/kubernetes/kube-openapi/blob/865597e52e25/pkg/internal/third_party/go-json-experiment/json/LICENSE +License URL: https://github.com/kubernetes/kube-openapi/blob/d427ff9ee9ad/pkg/internal/third_party/go-json-experiment/json/LICENSE ---------- Module: k8s.io/kube-openapi/pkg/validation/spec -Version: v0.0.0-20260603220949-865597e52e25 +Version: v0.0.0-20260721132016-d427ff9ee9ad License: Apache-2.0 -License URL: https://github.com/kubernetes/kube-openapi/blob/865597e52e25/pkg/validation/spec/LICENSE +License URL: https://github.com/kubernetes/kube-openapi/blob/d427ff9ee9ad/pkg/validation/spec/LICENSE ---------- Module: k8s.io/kubectl/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/kubectl/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/kubectl/blob/v0.36.4/LICENSE ---------- Module: k8s.io/kubernetes/pkg -Version: v1.36.3 +Version: v1.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/kubernetes/blob/v1.36.3/LICENSE +License URL: https://github.com/kubernetes/kubernetes/blob/v1.36.4/LICENSE ---------- Module: k8s.io/streaming/pkg -Version: v0.36.3 +Version: v0.37.0 License: Apache-2.0 -License URL: https://github.com/kubernetes/streaming/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/streaming/blob/v0.37.0/LICENSE ---------- Module: k8s.io/utils @@ -1739,9 +1775,9 @@ License URL: https://github.com/kubernetes-sigs/randfill/blob/v1.0.0/LICENSE ---------- Module: sigs.k8s.io/structured-merge-diff/v6 -Version: v6.4.0 +Version: v6.4.2 License: Apache-2.0 -License URL: https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.4.0/LICENSE +License URL: https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.4.2/LICENSE ---------- Module: sigs.k8s.io/yaml diff --git a/cli/cmd/apikey/api_key_integration_test.go b/cli/cmd/apikey/api_key_integration_test.go index d8a0671fd..dca24ed57 100644 --- a/cli/cmd/apikey/api_key_integration_test.go +++ b/cli/cmd/apikey/api_key_integration_test.go @@ -239,7 +239,7 @@ var _ = Describe("API Key Integration Tests", func() { Describe("Old API Key Detection and Warning", func() { var ( - cliPath = "../../oms" + cliPath = "../../../oms" ) Context("when using a 25-character old API key format", func() { @@ -318,7 +318,7 @@ var _ = Describe("API Key Integration Tests", func() { Describe("PreRun Hook Execution", func() { var ( - cliPath = "../../oms" + cliPath = "../../../oms" ) Context("when running any OMS command", func() { diff --git a/cli/cmd/apikey/apikey_suite_test.go b/cli/cmd/apikey/apikey_suite_test.go new file mode 100644 index 000000000..b8ee534f4 --- /dev/null +++ b/cli/cmd/apikey/apikey_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package apikey_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestApiKey(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ApiKey Suite") +} diff --git a/cli/cmd/apikey/register.go b/cli/cmd/apikey/register.go index d79633fb6..2edcac3e7 100644 --- a/cli/cmd/apikey/register.go +++ b/cli/cmd/apikey/register.go @@ -57,7 +57,7 @@ func AddRegisterCmd(list *cobra.Command, opts *util.GlobalOptions) { }, Opts: RegisterOpts{GlobalOptions: opts}, } - c.cmd.Flags().StringVarP(&c.Opts.Owner, "owner", "o", "", "Owner of the new API key") + c.cmd.Flags().StringVarP(&c.Opts.Owner, "owner", "o", "", "Owner of the new API key (must be a valid email address)") c.cmd.Flags().StringVarP(&c.Opts.Organization, "organization", "g", "", "Organization of the new API key") c.cmd.Flags().StringVarP(&c.Opts.Role, "role", "r", "Ext", "Role of the new API key. Available roles: Admin, Dev, Ext") c.cmd.Flags().StringVar(&c.Opts.ValidFor, "valid-for", "", "Validity duration of the new API key in days (e.g., 10d)") @@ -72,6 +72,10 @@ func (c *RegisterCmd) Register(p portal.Portal) (*portal.ApiKey, error) { return nil, fmt.Errorf("invalid role: %s. Available roles are: Admin, Dev, Ext", c.Opts.Role) } + if err := intutil.ValidateEmail(c.Opts.Owner); err != nil { + return nil, fmt.Errorf("invalid owner: %w", err) + } + var expiresAt time.Time if c.Opts.ValidFor != "" { validForDuration, err := intutil.GetDurationFromString(c.Opts.ValidFor) diff --git a/cli/cmd/apikey/register_test.go b/cli/cmd/apikey/register_test.go index 60effb6d7..122693934 100644 --- a/cli/cmd/apikey/register_test.go +++ b/cli/cmd/apikey/register_test.go @@ -30,7 +30,7 @@ var _ = Describe("RegisterCmd", func() { BeforeEach(func() { mockPortal = portal.NewMockPortal(GinkgoT()) validFor = "10d" - owner = "test-owner" + owner = "test-owner@example.com" organization = "test-org" role = apikey.API_KEY_ROLE_ADMIN c = apikey.RegisterCmd{ @@ -126,6 +126,15 @@ var _ = Describe("RegisterCmd", func() { Expect(err).To(MatchError(ContainSubstring("invalid role: InvalidRole"))) }) }) + + Context("when owner is not a valid email address", func() { + It("returns error for invalid owner", func() { + c.Opts.Owner = "not-an-email" + ak, err := c.Register(mockPortal) + Expect(ak).To(BeNil()) + Expect(err).To(MatchError(ContainSubstring("invalid owner"))) + }) + }) }) var _ = Describe("AddRegisterCmd", func() { diff --git a/cli/cmd/beta_vault_secret.go b/cli/cmd/beta_vault_secret.go index 2a5d8dd75..0751f599a 100644 --- a/cli/cmd/beta_vault_secret.go +++ b/cli/cmd/beta_vault_secret.go @@ -27,6 +27,7 @@ type BetaVaultSecretOpts struct { AgeKeyPath string Namespace string SecretName string + VaultType string } func (c *BetaVaultSecretCmd) RunE(_ *cobra.Command, _ []string) error { @@ -47,16 +48,26 @@ func (c *BetaVaultSecretCmd) RunE(_ *cobra.Command, _ []string) error { creator := vault.NewVaultSecretCreator(kubeClient) - return creator.CreateSecretFromFile(c.cmd.Context(), c.Opts.VaultFile, c.Opts.AgeKeyPath, c.Opts.Namespace, c.Opts.SecretName) + store, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.VaultFile, AgeKey: c.Opts.AgeKeyPath}) + if err != nil { + return fmt.Errorf("failed to load vault: %w", err) + } + + err = creator.CreateSecretFromStore(c.cmd.Context(), store, c.Opts.Namespace, c.Opts.SecretName) + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + return nil } func AddBetaVaultSecretCmd(parentCmd *cobra.Command, opts *util.GlobalOptions) { cmd := BetaVaultSecretCmd{ cmd: &cobra.Command{ Use: "vault-secret", - Short: "Create a Kubernetes secret from a SOPS-encrypted vault file", - Long: packageio.Long(`Create a Kubernetes secret from a SOPS-encrypted prod.vault.yaml file. - Reads the encrypted vault file, decrypts it using the age key, and creates a Kubernetes secret + Short: "Create a Kubernetes secret from a vault file", + Long: packageio.Long(`Create a Kubernetes secret from a prod.vault.yaml file. + Loads the selected vault type and creates a Kubernetes secret with all the vault entries as key-value pairs in the target cluster.`), Example: util.FormatExamples("vault-secret", []packageio.Example{ {Cmd: "--vault-file prod.vault.yaml --namespace default --secret-name vault-secrets", Desc: "Create secret using default age key location"}, @@ -66,8 +77,9 @@ func AddBetaVaultSecretCmd(parentCmd *cobra.Command, opts *util.GlobalOptions) { Opts: BetaVaultSecretOpts{GlobalOptions: opts}, } - cmd.cmd.Flags().StringVar(&cmd.Opts.VaultFile, "vault-file", "", "Path to the SOPS-encrypted vault file (required)") - cmd.cmd.Flags().StringVar(&cmd.Opts.AgeKeyPath, "age-key", "", "Path to the age key file (optional, will use defaults if not provided)") + cmd.cmd.Flags().StringVar(&cmd.Opts.VaultFile, "vault-file", "", "Path to the vault file (required)") + cmd.cmd.Flags().StringVar(&cmd.Opts.AgeKeyPath, "age-key", "", "Path to the age key file (required for sops unless an age key environment variable is set)") + cmd.cmd.Flags().StringVar(&cmd.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") cmd.cmd.Flags().StringVar(&cmd.Opts.Namespace, "namespace", "codesphere", "Kubernetes namespace where the secret will be created") cmd.cmd.Flags().StringVar(&cmd.Opts.SecretName, "secret-name", "cs-vault", "Name of the Kubernetes secret to create") diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 8f87eae55..334158061 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -22,6 +22,8 @@ import ( intutil "github.com/codesphere-cloud/oms/internal/util" ) +// BootstrapGcpCmd holds the bootstrap-gcp command parameters, which +// provisions the GCP infrastructure to install a Codesphere cluster on. type BootstrapGcpCmd struct { cmd *cobra.Command Opts *util.GlobalOptions @@ -29,11 +31,13 @@ type BootstrapGcpCmd struct { CodesphereEnv *gcp.CodesphereEnvironment InputRegistryType string SSHQuiet bool + // experiments backs the deprecated --experiments flag; its values // are folded into the internal bucket for backwards compatibility. experiments []string } +// RunE is the starting point for the bootstrap command that executes the bootstrap logic. func (c *BootstrapGcpCmd) RunE(_ *cobra.Command, args []string) error { err := c.BootstrapGcp() if err != nil { @@ -43,6 +47,7 @@ func (c *BootstrapGcpCmd) RunE(_ *cobra.Command, args []string) error { return nil } +// AddBootstrapGcpCmd registers the gcp bootstrap command in the parent command func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { bootstrapGcpCmd := BootstrapGcpCmd{ cmd: &cobra.Command{ @@ -65,10 +70,11 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.ProjectTTL, "project-ttl", "2h", "Time to live for the GCP project. Cleanup workflows will remove it afterwards. (default: 2 hours)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.BillingAccount, "billing-account", "", "GCP Billing Account ID (required)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.BaseDomain, "base-domain", "", "Base domain for Codesphere (required)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppClientID, "github-app-client-id", "", "GitHub App Client ID (required)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppClientSecret, "github-app-client-secret", "", "GitHub App Client Secret (required)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitLabAppClientID, "gitlab-app-client-id", "", "GitLab App Client ID (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitLabAppClientSecret, "gitlab-app-client-secret", "", "GitLab App Client Secret (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsDir, "secrets-dir", "/etc/codesphere/secrets", "Directory for secrets (default: /etc/codesphere/secrets)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.FolderID, "folder-id", "", "GCP Folder ID (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SSHPublicKeyPath, "ssh-public-key-path", "~/.ssh/id_rsa.pub", "SSH Public Key Path (default: ~/.ssh/id_rsa.pub)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SSHPrivateKeyPath, "ssh-private-key-path", "~/.ssh/id_rsa", "SSH Private Key Path (default: ~/.ssh/id_rsa)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.BitbucketAppClientID, "bitbucket-app-client-id", "", "Bitbucket App Client ID (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.BitbucketAppClientSecret, "bitbucket-app-client-secret", "", "Bitbucket App Client Secret (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.AzureDevOpsAppClientID, "azure-devops-app-client-id", "", "Azure DevOps App Client ID (optional)") @@ -77,23 +83,35 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OidcIssuerURL, "oidc-issuer-url", "", "OIDC OAuth provider issuer URL (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OidcClientID, "oidc-client-id", "", "OIDC OAuth provider Client ID (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OidcClientSecret, "oidc-client-secret", "", "OIDC OAuth provider Client Secret (optional)") + + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppClientID, "github-app-client-id", "", "GitHub App Client ID (required)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppClientSecret, "github-app-client-secret", "", "GitHub App Client Secret (required)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitLabAppClientID, "gitlab-app-client-id", "", "GitLab App Client ID (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitLabAppClientSecret, "gitlab-app-client-secret", "", "GitLab App Client Secret (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubPAT, "github-pat", "", "GitHub Personal Access Token used for direct image access and fetching team SSH keys. Required when using --github-team-org/--github-team-slug. Required scopes: read:packages, read:org.") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubAppName, "github-app-name", "", "GitHub App Name (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubTeamOrg, "github-team-org", "", "GitHub organization used to fetch team SSH keys (optional, used with --github-team-slug). Requires --github-pat with at least the read:org scope.") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.GitHubTeamSlug, "github-team-slug", "", "GitHub team slug used to fetch team SSH keys (optional, used with --github-team-org). Requires --github-pat with at least the read:org scope.") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsDir, "secrets-dir", "/etc/codesphere/secrets", "Directory for secrets (default: /etc/codesphere/secrets)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.FolderID, "folder-id", "", "GCP Folder ID (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SSHPublicKeyPath, "ssh-public-key-path", "~/.ssh/id_rsa.pub", "SSH Public Key Path (default: ~/.ssh/id_rsa.pub)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SSHPrivateKeyPath, "ssh-private-key-path", "~/.ssh/id_rsa", "SSH Private Key Path (default: ~/.ssh/id_rsa)") + + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DNSProjectID, "dns-project-id", "", "GCP Project ID for Cloud DNS (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DNSZoneName, "dns-zone-name", "oms-testing", "Cloud DNS Zone Name (optional)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.Preemptible, "preemptible", false, "Use preemptible VMs for Codesphere infrastructure. Mutually exclusive with --spot-vms (default: false)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.SpotVMs, "spot-vms", false, "Use Spot VMs for Codesphere infrastructure. Falls back to standard VMs if spot capacity unavailable. Mutually exclusive with --preemptible (default: false)") + flags.Int64Var(&bootstrapGcpCmd.CodesphereEnv.RootDiskSize, "root-disk-size", 50, "Instance root disk size in GB (default: 50)") + + flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.WriteConfig, "write-config", true, "Write generated install config to file (default: true)") + flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.RecoverConfig, "recover-config", false, "Recover previously generated install config from the jumpbox. This will overwrite the local config! (default: false)") + flags.BoolVar(&bootstrapGcpCmd.SSHQuiet, "ssh-quiet", false, "Suppress SSH command output (default: false)") + flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.CreateTestUser, "create-test-user", false, "Create a test user with API token on the bootstrapped instance for smoke testing (default: false)") + + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallConfigPath, "install-config", "config.yaml", "Path to install config file (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsFilePath, "secrets-file", "prod.vault.yaml", "Path to secrets files (optional)") + flags.IntVar(&bootstrapGcpCmd.CodesphereEnv.DatacenterID, "datacenter-id", 1, "Datacenter ID (default: 1)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DatacenterName, "datacenter-name", "dev", "Datacenter name (default: dev)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.CustomPgIP, "custom-pg-ip", "", "Custom PostgreSQL IP (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.Region, "region", "europe-west4", "GCP Region (default: europe-west4)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.Zone, "zone", "europe-west4-a", "GCP Zone (default: europe-west4-a)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DNSProjectID, "dns-project-id", "", "GCP Project ID for Cloud DNS (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DNSZoneName, "dns-zone-name", "oms-testing", "Cloud DNS Zone Name (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallLocal, "install-local", "", "Install Codesphere from local package (default: none)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallVersion, "install-version", "", "Codesphere version to install (default: none)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallHash, "install-hash", "", "Codesphere package hash to install (default: none)") @@ -114,14 +132,6 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.PrometheusRemoteWritePassword, "prometheus-remote-write-password", "", "Prometheus remote write password stored in the generated vault (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.ClusterAdminEmail, "cluster-admin-email", "", "Email address of the initial cluster admin. Written to the install config and applied as the cluster-admin-email secret before the Codesphere platform is installed (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallConfigPath, "install-config", "config.yaml", "Path to install config file (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsFilePath, "secrets-file", "prod.vault.yaml", "Path to secrets files (optional)") - flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.WriteConfig, "write-config", true, "Write generated install config to file (default: true)") - flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.RecoverConfig, "recover-config", false, "Recover previously generated install config from the jumpbox. This will overwrite the local config! (default: false)") - flags.BoolVar(&bootstrapGcpCmd.SSHQuiet, "ssh-quiet", false, "Suppress SSH command output (default: false)") - flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.CreateTestUser, "create-test-user", false, "Create a test user with API token on the bootstrapped instance for smoke testing (default: false)") - flags.Int64Var(&bootstrapGcpCmd.CodesphereEnv.RootDiskSize, "root-disk-size", 50, "Instance root disk size in GB (default: 50)") - flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.GoogleACMEIssuer, "google-acme-issuer", false, "Use Google Public CA as the ACME issuer instead of Let's Encrypt. External Account Binding credentials are obtained automatically via the publicca API (default: false)") flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.ACMEStaging, "acme-staging", false, "Use the Let's Encrypt staging ACME endpoint (certificates are not browser-trusted)") @@ -146,14 +156,25 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { AddBootstrapGcpRestartVMsCmd(bootstrapGcpCmd.cmd, opts) } +// BootstrapGcp initializes the bootstrapper, validates the input flags and starts bootstrapping. +// Returns an error if validation or github client connection fails func (c *BootstrapGcpCmd) BootstrapGcp() error { ctx := c.cmd.Context() stlog := bootstrap.NewStepLogger(false) - icg := installer.NewInstallConfigManager() + + icg, err := installer.NewInstallConfigManager("plain", "") + if err != nil { + return fmt.Errorf("failed to initialize conig manager: %w", err) + } + gcpClient := gcp.NewGCPClient(ctx, stlog, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) fw := intutil.NewFilesystemWriter() portalClient := portal.NewPortalClient() - githubClient := github.NewGitHubClient(ctx, c.CodesphereEnv.GitHubPAT) + + githubClient, err := github.NewGitHubClient(ctx, c.CodesphereEnv.GitHubPAT) + if err != nil { + return fmt.Errorf("failed to create github client: %w", err) + } bs, err := gcp.NewGCPBootstrapper( ctx, @@ -169,10 +190,11 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { githubClient, ) if err != nil { - return err + return fmt.Errorf("failed to create gcp bootstrapper: %w", err) } c.CodesphereEnv.RegistryType = gcp.RegistryType(c.InputRegistryType) + c.CodesphereEnv.OmsWorkdir = c.Env.GetOmsWorkdir() if c.CodesphereEnv.GitHubPAT != "" { c.CodesphereEnv.RegistryType = gcp.RegistryTypeGitHub @@ -200,6 +222,7 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { if bs.Env.Jumpbox != nil && bs.Env.Jumpbox.GetExternalIP() != "" { log.Printf("To debug on the jumpbox host:\nssh-add $SSH_KEY_PATH; ssh -o StrictHostKeyChecking=no -o ForwardAgent=yes -o SendEnv=OMS_PORTAL_API_KEY root@%s", bs.Env.Jumpbox.GetExternalIP()) } + return fmt.Errorf("failed to bootstrap GCP: %w", err) } @@ -214,11 +237,14 @@ func (c *BootstrapGcpCmd) BootstrapGcp() error { packageName := "-installer" installCmd := "oms install codesphere -c /etc/codesphere/config.yaml -k /etc/codesphere/secrets/age_key.txt --vault /etc/codesphere/secrets/prod.vault.yaml" + if gcp.RegistryType(bs.Env.RegistryType) == gcp.RegistryTypeGitHub { log.Printf("You set a GitHub PAT for direct image access. Make sure to use a lite package, as VM root disk sizes are reduced.") + installCmd += " -s load-container-images" packageName += "-lite" } + log.Printf("example install command (run from jumpbox):\n%s -p %s.tar.gz", installCmd, packageName) return nil diff --git a/cli/cmd/bootstrap_gcp_cleanup_test.go b/cli/cmd/bootstrap_gcp_cleanup_test.go index 3337fd4ca..dc4873ef9 100644 --- a/cli/cmd/bootstrap_gcp_cleanup_test.go +++ b/cli/cmd/bootstrap_gcp_cleanup_test.go @@ -73,6 +73,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { Expect(err).NotTo(HaveOccurred()) var decoded gcp.CodesphereEnvironment + err = json.Unmarshal(data, &decoded) Expect(err).NotTo(HaveOccurred()) @@ -93,6 +94,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { Expect(err).NotTo(HaveOccurred()) var decoded gcp.CodesphereEnvironment + err = json.Unmarshal(data, &decoded) Expect(err).NotTo(HaveOccurred()) @@ -240,6 +242,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { Context("when project ID is provided via flag", func() { It("should use the provided project ID", func() { cleanupCmd.Opts.ProjectID = "flag-project" + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) mockGCPClient.EXPECT().IsOMSManagedProject("flag-project").Return(false, nil) @@ -252,6 +255,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { Context("when OMS management check fails", func() { It("should return the verification error", func() { cleanupCmd.Opts.ProjectID = "test-project" + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) mockGCPClient.EXPECT().IsOMSManagedProject("test-project").Return(false, errors.New("API error")) @@ -265,6 +269,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { It("should skip OMS management check and proceed to confirmation", func() { cleanupCmd.Opts.ProjectID = "test-project" cleanupCmd.Opts.Force = true + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) mockGCPClient.EXPECT().DeleteProject("test-project").Return(nil) @@ -277,6 +282,7 @@ var _ = Describe("BootstrapGcpCleanupCmd", func() { It("should abort the cleanup", func() { cleanupCmd.Opts.ProjectID = "test-project" deps.ConfirmReader = bytes.NewBufferString("wrong-input\n") + mockFileIO.EXPECT().Exists("/tmp/test-infra.json").Return(false) mockGCPClient.EXPECT().IsOMSManagedProject("test-project").Return(true, nil) diff --git a/cli/cmd/bootstrap_gcp_postconfig.go b/cli/cmd/bootstrap_gcp_postconfig.go index d0eb064b9..8df73d2ef 100644 --- a/cli/cmd/bootstrap_gcp_postconfig.go +++ b/cli/cmd/bootstrap_gcp_postconfig.go @@ -31,17 +31,24 @@ type BootstrapGcpPostconfigOpts struct { func (c *BootstrapGcpPostconfigCmd) RunE(_ *cobra.Command, args []string) error { log.Printf("running post-configuration steps...") - icg := installer.NewInstallConfigManager() + icg, err := installer.NewInstallConfigManager("plain", "") + if err != nil { + return fmt.Errorf("failed to initialize config manager: %w", err) + } + fw := intutil.NewFilesystemWriter() infraFilePath := gcp.GetInfraFilePath() + codesphereEnv, exists, err := gcp.LoadInfraFile(fw, infraFilePath) if err != nil { return fmt.Errorf("failed to load gcp infra file: %w", err) } + if !exists { return fmt.Errorf("gcp infra file not found at %s", infraFilePath) } + c.CodesphereEnv = codesphereEnv err = icg.LoadInstallConfigFromFile(c.Opts.InstallConfigPath) diff --git a/cli/cmd/bootstrap_gcp_restart_vms.go b/cli/cmd/bootstrap_gcp_restart_vms.go index 900b93535..d47e19a51 100644 --- a/cli/cmd/bootstrap_gcp_restart_vms.go +++ b/cli/cmd/bootstrap_gcp_restart_vms.go @@ -28,32 +28,50 @@ type BootstrapGcpRestartVMsOpts struct { Name string } -// resolveProjectAndZone returns the project ID and zone from flags or the infra file. -// If both flags are set they are used directly; if neither is set, the infra file is read. -// Providing only one of --project-id / --zone is an error. -func (c *BootstrapGcpRestartVMsCmd) resolveProjectAndZone(fw intutil.FileIO) (string, string, error) { +// resolveEnvironment returns the environment to restart VMs in. Project ID and zone come from +// the flags or, when neither is set, from the infra file. Providing only one of +// --project-id / --zone is an error. +// +// The data center layout always comes from the infra file, since it determines the VM names. It +// is read best-effort when the flags supply project and zone, in which case a missing file just +// means single-data-center names. +func (c *BootstrapGcpRestartVMsCmd) resolveEnvironment(fw intutil.FileIO) (*gcp.CodesphereEnvironment, error) { projectID := c.Opts.ProjectID zone := c.Opts.Zone if (projectID == "") != (zone == "") { - return "", "", fmt.Errorf("--project-id and --zone must be provided together") - } - if projectID != "" { - return projectID, zone, nil + return nil, fmt.Errorf("--project-id and --zone must be provided together") } infraFilePath := gcp.GetInfraFilePath() + infraEnv, exists, err := gcp.LoadInfraFile(fw, infraFilePath) if err != nil { - return "", "", fmt.Errorf("failed to load infra file: %w", err) - } - if !exists { - return "", "", fmt.Errorf("infra file not found at %s; use --project-id and --zone flags", infraFilePath) + if projectID == "" { + return nil, fmt.Errorf("failed to load infra file: %w", err) + } + + log.Printf("Warning: %v", err) } - if infraEnv.ProjectID == "" || infraEnv.Zone == "" { - return "", "", fmt.Errorf("infra file is missing project ID or zone; use --project-id and --zone flags") + + if projectID == "" { + if !exists { + return nil, fmt.Errorf("infra file not found at %s; use --project-id and --zone flags", infraFilePath) + } + + if infraEnv.ProjectID == "" || infraEnv.Zone == "" { + return nil, fmt.Errorf("infra file is missing project ID or zone; use --project-id and --zone flags") + } + + projectID, zone = infraEnv.ProjectID, infraEnv.Zone } - return infraEnv.ProjectID, infraEnv.Zone, nil + + return &gcp.CodesphereEnvironment{ + ProjectID: projectID, + Zone: zone, + MultiDC: infraEnv.MultiDC, + DataCenters: infraEnv.DataCenters, + }, nil } func (c *BootstrapGcpRestartVMsCmd) RunE(_ *cobra.Command, _ []string) error { @@ -61,17 +79,14 @@ func (c *BootstrapGcpRestartVMsCmd) RunE(_ *cobra.Command, _ []string) error { stlog := bootstrap.NewStepLogger(false) fw := intutil.NewFilesystemWriter() - projectID, zone, err := c.resolveProjectAndZone(fw) + csEnv, err := c.resolveEnvironment(fw) if err != nil { return err } - gcpClient := gcp.NewGCPClient(ctx, stlog, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) + projectID, zone := csEnv.ProjectID, csEnv.Zone - csEnv := &gcp.CodesphereEnvironment{ - ProjectID: projectID, - Zone: zone, - } + gcpClient := gcp.NewGCPClient(ctx, stlog, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) bs, err := gcp.NewGCPBootstrapper( ctx, @@ -83,15 +98,19 @@ func (c *BootstrapGcpRestartVMsCmd) RunE(_ *cobra.Command, _ []string) error { if c.Opts.Name != "" { log.Printf("Restarting VM %s in project %s (zone %s)...", c.Opts.Name, projectID, zone) + if err := bs.RestartVM(c.Opts.Name); err != nil { return fmt.Errorf("failed to restart VM: %w", err) } + log.Printf("VM %s restarted successfully.", c.Opts.Name) } else { log.Printf("Restarting all VMs in project %s (zone %s)...", projectID, zone) + if err := bs.RestartVMs(); err != nil { return fmt.Errorf("failed to restart VMs: %w", err) } + log.Printf("All VMs restarted successfully.") } @@ -112,6 +131,7 @@ func AddBootstrapGcpRestartVMsCmd(bootstrapGcp *cobra.Command, opts *util.Global {Desc: "Restart all VMs using project info from the local infra file"}, {Cmd: "--name jumpbox", Desc: "Restart only the jumpbox VM"}, {Cmd: "--name k0s-1", Desc: "Restart a specific k0s node"}, + {Cmd: "--name k0s-1-dc2", Desc: "Restart a node of the second data center of a --multi-dc bootstrap"}, {Cmd: "--project-id my-project --zone us-central1-a", Desc: "Restart all VMs with explicit project and zone"}, {Cmd: "--project-id my-project --zone us-central1-a --name ceph-1", Desc: "Restart a specific VM with explicit project and zone"}, }), diff --git a/cli/cmd/bootstrap_gcp_restart_vms_test.go b/cli/cmd/bootstrap_gcp_restart_vms_test.go index 7a9be24d2..3b4a93afd 100644 --- a/cli/cmd/bootstrap_gcp_restart_vms_test.go +++ b/cli/cmd/bootstrap_gcp_restart_vms_test.go @@ -28,6 +28,7 @@ var _ = Describe("BootstrapGcpRestartVMsCmd", func() { c, _, err := parentCmd.Find([]string{"restart-vms"}) Expect(err).NotTo(HaveOccurred()) Expect(c).NotTo(BeNil()) + return c } diff --git a/cli/cmd/bootstrap_local.go b/cli/cmd/bootstrap_local.go index 19c4fb9fd..c9dcf8315 100644 --- a/cli/cmd/bootstrap_local.go +++ b/cli/cmd/bootstrap_local.go @@ -73,7 +73,7 @@ func AddBootstrapLocalCmd(parent *cobra.Command) { // Installer flags.BoolVarP(&bootstrapLocalCmd.Yes, "yes", "y", false, "Auto-approve the local bootstrapping warning prompt") flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.InstallVersion, "install-version", "", "Codesphere version to install (downloaded from the OMS portal)") - flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.InstallHash, "install-hash", "", "Codesphere package hash (required when install-version is set)") + flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.InstallHash, "install-hash", "", "Optional Codesphere package hash used to select a specific build") flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.InstallLocal, "install-local", "", "Path to a local installer package (tar.gz or unpacked directory)") // Registry flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.RegistryUser, "registry-user", "", "Custom Registry username") @@ -95,9 +95,10 @@ func AddBootstrapLocalCmd(parent *cobra.Command) { flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.InstallDir, "install-dir", ".installer", "Directory for config, secrets, and bundle files") flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.InstallConfigPath, "install-config", "", "Path to install config file (default: /config.yaml)") flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.SecretsFilePath, "secrets-file", "", "Path to secrets file (default: /prod.vault.yaml)") + flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.CephDeviceFilter, "ceph-device-filter", "", "Regular expression selecting Ceph block devices by name") + flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.CephDevicePathFilter, "ceph-device-path-filter", "", "Regular expression selecting Ceph block devices by path") // ArgoCD integration - flags.BoolVar(&bootstrapLocalCmd.CodesphereEnv.UseArgoCD, "argocd", true, "After infra setup: install ArgoCD, update the OCI pull secret, and install pc-apps from the BOM version") - flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.ArgoCDRegistryURL, "registry-url", "oci://ghcr.io/codesphere-cloud/charts", "OCI registry URL used for the ArgoCD helm pull secret (only relevant with --argocd)") + flags.StringVar(&bootstrapLocalCmd.CodesphereEnv.ArgoCDRegistryURL, "registry-url", "oci://ghcr.io/codesphere-cloud/charts", "OCI registry URL used for the ArgoCD helm pull secret") bootstrapLocalCmd.cmd.RunE = bootstrapLocalCmd.RunE util.MarkFlagRequired(bootstrapLocalCmd.cmd, "registry-user") @@ -141,19 +142,18 @@ func (c *BootstrapLocalCmd) BootstrapLocal() error { } stlog := bootstrap.NewStepLogger(false) - icg := installer.NewInstallConfigManager() + + icg, err := installer.NewInstallConfigManager("plain", "") + if err != nil { + return fmt.Errorf("failed to initialize config manager: %w", err) + } fw := intutil.NewFilesystemWriter() kubeClient, restConfig, err := c.GetKubeClient(ctx) if err != nil { return fmt.Errorf("failed to initialize Kubernetes client: %w", err) } - helmClient, err := installer.NewHelmClient("codesphere") - if err != nil { - return fmt.Errorf("failed to initialize Helm client: %w", err) - } - - bs := local.NewLocalBootstrapper(ctx, stlog, kubeClient, restConfig, fw, icg, helmClient, c.CodesphereEnv) + bs := local.NewLocalBootstrapper(ctx, stlog, kubeClient, restConfig, fw, icg, c.CodesphereEnv) return bs.Bootstrap() } diff --git a/cli/cmd/codesphere/codesphere_suite_test.go b/cli/cmd/codesphere/codesphere_suite_test.go new file mode 100644 index 000000000..b34e884ff --- /dev/null +++ b/cli/cmd/codesphere/codesphere_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package codesphere_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCodesphere(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Codesphere Command Suite") +} diff --git a/cli/cmd/codesphere/install_codesphere.go b/cli/cmd/codesphere/install_codesphere.go index 141bf27b9..96c8c23e0 100644 --- a/cli/cmd/codesphere/install_codesphere.go +++ b/cli/cmd/codesphere/install_codesphere.go @@ -41,6 +41,7 @@ type InstallCodesphereOpts struct { ConfigPath string Vault string PrivKey string + VaultType string SkipSteps []string CodesphereOnly bool DirectConnection bool @@ -55,6 +56,9 @@ type InstallCodesphereOpts struct { } func (c *InstallCodesphereCmd) RunE(cmd *cobra.Command, _ []string) error { + if err := validateInstallCodesphereVault(c.Opts); err != nil { + return err + } ctx := cmd.Context() effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { @@ -116,14 +120,14 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { }, }), }, - Opts: &InstallCodesphereOpts{GlobalOptions: opts}, + Opts: &InstallCodesphereOpts{GlobalOptions: opts, VaultType: string(vault.TypeSOPS)}, Env: env.NewEnv(), } codesphere.cmd.PersistentFlags().StringVarP(&codesphere.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load binaries, installer etc. from") codesphere.cmd.PersistentFlags().BoolVarP(&codesphere.Opts.Force, "force", "f", false, "Enforce package extraction") codesphere.cmd.PersistentFlags().StringArrayVarP(&codesphere.Opts.Configs, "config", "c", nil, "Path to a Codesphere Private Cloud configuration file (yaml). Can be specified multiple times and merged in order") - codesphere.cmd.PersistentFlags().StringVar(&codesphere.Opts.Vault, "vault", "", "Path to the SOPS-encrypted prod.vault.yaml file used for config templating") - codesphere.cmd.PersistentFlags().StringVarP(&codesphere.Opts.PrivKey, "priv-key", "k", "", "Path to the private key to encrypt/decrypt secrets") + codesphere.cmd.PersistentFlags().StringVar(&codesphere.Opts.Vault, "vault", "", "Path to the prod.vault.yaml file used for config templating") + codesphere.cmd.PersistentFlags().StringVarP(&codesphere.Opts.PrivKey, "priv-key", "k", "", "Path to the age private key (required for sops unless an age key environment variable is set)") codesphere.cmd.PersistentFlags().StringSliceVarP(&codesphere.Opts.SkipSteps, "skip-steps", "s", []string{}, "Steps to be skipped. E.g. copy-dependencies, extract-dependencies, load-container-images, ceph, postgres, kubernetes, docker, argocd") codesphere.cmd.PersistentFlags().BoolVar(&codesphere.Opts.DirectConnection, "direct-connection", false, "Use direct connection for installation, requires having access to the cluster nodes from your machine") codesphere.cmd.PersistentFlags().BoolVar(&codesphere.Opts.AutoApprove, "auto-approve", true, "Auto approve confirmation prompts with default values") @@ -137,7 +141,6 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { util.MarkPersistentFlagRequired(codesphere.cmd, "package") util.MarkPersistentFlagRequired(codesphere.cmd, "config") - util.MarkPersistentFlagRequired(codesphere.cmd, "priv-key") util.AddCmd(install, codesphere.cmd) @@ -148,6 +151,21 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { AddInstallCodespherePlatformCmd(codesphere.cmd, codesphere.Opts) } +// validateInstallCodesphereVault enforces the current TypeScript installer +// contract without changing the selected type on the command options. +func validateInstallCodesphereVault(opts *InstallCodesphereOpts) error { + if opts.VaultType != string(vault.TypeSOPS) { + return fmt.Errorf("install codesphere requires vault type %q", vault.TypeSOPS) + } + + err := vault.ValidateConfiguration(vault.TypeSOPS, opts.PrivKey) + if err != nil { + return fmt.Errorf("failed to validate install config: %w", err) + } + + return nil +} + func sharedInstallCodesphereSteps() []string { return []string{"copy-dependencies", "extract-dependencies"} } @@ -168,7 +186,16 @@ func prepareInstallConfig(opts *InstallCodesphereOpts, cm installer.ConfigManage return nil, files.RootConfig{}, func() {}, fmt.Errorf("no config.yaml input provided: at least one config file is required") } - store := vault.NewLazyVaultTemplatingSecretStore(opts.Vault, opts.PrivKey) + var store *vault.VaultTemplatingSecretStore + + if opts.Vault != "" { + backend, err := vault.NewFromString(opts.VaultType, vault.Options{Path: opts.Vault, AgeKey: opts.PrivKey}) + if err != nil { + return nil, files.RootConfig{}, func() {}, fmt.Errorf("failed to load vault: %w", err) + } + + store = vault.NewLazyVaultTemplatingSecretStoreWithVault(backend) + } cleanupFns := []func(){} cleanup := func() { for i := len(cleanupFns) - 1; i >= 0; i-- { diff --git a/cli/cmd/codesphere/install_codesphere_config_test.go b/cli/cmd/codesphere/install_codesphere_config_test.go index f26872d34..73c69da78 100644 --- a/cli/cmd/codesphere/install_codesphere_config_test.go +++ b/cli/cmd/codesphere/install_codesphere_config_test.go @@ -13,6 +13,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -164,7 +165,7 @@ pcApps: Expect(exec.Command("age-keygen", "-o", ageKeyPath).Run()).To(Succeed()) recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() Expect(err).ToNot(HaveOccurred()) - Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) + Expect(sops.EncryptFile(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) opts := &InstallCodesphereOpts{ Configs: []string{basePath, overlayPath}, @@ -232,3 +233,16 @@ func installCodesphereSopsAndAgeAvailable() bool { } return true } + +var _ = Describe("install codesphere vault type", func() { + It("accepts sops", func() { + opts := &InstallCodesphereOpts{VaultType: string(vault.TypeSOPS), PrivKey: "age-key.txt"} + Expect(validateInstallCodesphereVault(opts)).To(Succeed()) + }) + + It("rejects plain vaults at the command boundary", func() { + opts := &InstallCodesphereOpts{VaultType: string(vault.TypePlain), PrivKey: "age-key.txt"} + err := validateInstallCodesphereVault(opts) + Expect(err).To(MatchError(`install codesphere requires vault type "sops"`)) + }) +}) diff --git a/cli/cmd/codesphere/install_codesphere_dependencies.go b/cli/cmd/codesphere/install_codesphere_dependencies.go index 62f47ac8d..8abb936d5 100644 --- a/cli/cmd/codesphere/install_codesphere_dependencies.go +++ b/cli/cmd/codesphere/install_codesphere_dependencies.go @@ -16,6 +16,7 @@ import ( "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/installer" argocdinstaller "github.com/codesphere-cloud/oms/internal/installer/argocd" + "github.com/codesphere-cloud/oms/internal/installer/bom" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/system" "github.com/spf13/cobra" @@ -33,6 +34,9 @@ type InstallCodesphereDepenciesCmd struct { } func (c *InstallCodesphereDepenciesCmd) RunE(_ *cobra.Command, _ []string) error { + if err := validateInstallCodesphereVault(c.Opts); err != nil { + return err + } effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err @@ -60,7 +64,7 @@ func installCodesphereDepencies(opts *InstallCodesphereOpts, cfg files.RootConfi AutoApprove: opts.AutoApprove, } - installVault, restConfig, err := installer.VaultAndRESTConfig(opts.Vault, opts.PrivKey, cfg) + installVault, restConfig, err := installer.VaultAndRESTConfig(opts.Vault, opts.PrivKey, opts.VaultType, cfg) if err != nil { return fmt.Errorf("failed to get vault and Kubernetes config: %w", err) } @@ -109,9 +113,18 @@ func installCodesphereDepencies(opts *InstallCodesphereOpts, cfg files.RootConfi // installArgoCDAndApps runs ArgoCD install, vault secret sync, and pc-apps install // before the main dependency steps. func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm installer.PackageManager, installVault *files.InstallVault, restConfig *rest.Config, kubeClient ctrlclient.Client, stlog *bootstrap.StepLogger) error { + bomConfig, err := bom.Parse(pm.GetDependencyPath("bom.json")) + if err != nil { + return fmt.Errorf("failed to parse installer BOM: %w", err) + } + var install *argocdinstaller.AppInstaller - if err := stlog.Substep("Initialize ArgoCD installer", func() error { + if err := stlog.Substep("Load vault data", func() error { + installVault, restConfig, err := installer.VaultAndRESTConfig(opts.Vault, opts.PrivKey, opts.VaultType, cfg) + if err != nil { + return fmt.Errorf("failed to load vault data and REST config: %w", err) + } registryPassword := "" if secret := installVault.GetSecret(files.SecretRegistryPassword); secret != nil && secret.Fields != nil { registryPassword = secret.Fields.Password @@ -159,7 +172,7 @@ func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm return err } if err := stlog.Substep("Install pc-apps", func() error { - return install.InstallPCApps(context.Background(), pm.GetDependencyPath("bom.json")) + return install.InstallPCApps(context.Background(), bomConfig) }); err != nil { return err } diff --git a/cli/cmd/codesphere/install_codesphere_infra.go b/cli/cmd/codesphere/install_codesphere_infra.go index 70274e9bf..a18b0bbdc 100644 --- a/cli/cmd/codesphere/install_codesphere_infra.go +++ b/cli/cmd/codesphere/install_codesphere_infra.go @@ -24,6 +24,9 @@ type InstallCodesphereInfraCmd struct { } func (c *InstallCodesphereInfraCmd) RunE(_ *cobra.Command, _ []string) error { + if err := validateInstallCodesphereVault(c.Opts); err != nil { + return err + } effectiveOpts, _, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err diff --git a/cli/cmd/codesphere/install_codesphere_platform.go b/cli/cmd/codesphere/install_codesphere_platform.go index 7b9a7c3e8..39b400ae9 100644 --- a/cli/cmd/codesphere/install_codesphere_platform.go +++ b/cli/cmd/codesphere/install_codesphere_platform.go @@ -25,6 +25,9 @@ type InstallCodespherePlatformCmd struct { } func (c *InstallCodespherePlatformCmd) RunE(cmd *cobra.Command, _ []string) error { + if err := validateInstallCodesphereVault(c.Opts); err != nil { + return err + } effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err @@ -35,7 +38,7 @@ func (c *InstallCodespherePlatformCmd) RunE(cmd *cobra.Command, _ []string) erro } func installCodespherePlatform(ctx context.Context, opts *InstallCodesphereOpts, cfg files.RootConfig, env env.Env) error { - if err := installer.EnsureClusterAdminSecret(ctx, opts.Vault, opts.PrivKey, cfg); err != nil { + if err := installer.EnsureClusterAdminSecret(ctx, opts.Vault, opts.PrivKey, opts.VaultType, cfg); err != nil { return fmt.Errorf("failed to set cluster admin email: %w", err) } diff --git a/cli/cmd/codesphere/install_codesphere_test.go b/cli/cmd/codesphere/install_codesphere_test.go index 2e3623200..d2970d830 100644 --- a/cli/cmd/codesphere/install_codesphere_test.go +++ b/cli/cmd/codesphere/install_codesphere_test.go @@ -32,6 +32,8 @@ var _ = Describe("InstallCodesphereCmd", func() { GlobalOptions: globalOpts, Package: "codesphere-v1.66.0-installer-lite.tar.gz", Force: false, + VaultType: "sops", + PrivKey: "age-key.txt", } c = codesphere.InstallCodesphereCmd{ Opts: opts, @@ -124,6 +126,7 @@ var _ = Describe("AddInstallCodesphereCmd", func() { vaultFlag := codesphereCmd.PersistentFlags().Lookup("vault") Expect(vaultFlag).NotTo(BeNil()) Expect(vaultFlag.DefValue).To(Equal("")) + Expect(codesphereCmd.PersistentFlags().Lookup("vault-type")).To(BeNil()) skipStepFlag := codesphereCmd.PersistentFlags().Lookup("skip-steps") Expect(skipStepFlag).NotTo(BeNil()) diff --git a/cli/cmd/codesphere/smoketest_codesphere.go b/cli/cmd/codesphere/smoketest_codesphere.go index 54425365b..a93b78bbb 100644 --- a/cli/cmd/codesphere/smoketest_codesphere.go +++ b/cli/cmd/codesphere/smoketest_codesphere.go @@ -40,14 +40,15 @@ type SmoketestCodesphereCmd struct { Opts *teststeps.SmoketestCodesphereOpts } -func (c *SmoketestCodesphereCmd) RunE(_ *cobra.Command, args []string) error { +// RunE runs the smoke test against the configured Codesphere installation. +func (c *SmoketestCodesphereCmd) RunE(cmd *cobra.Command, _ []string) error { client, err := codesphere.NewClient(c.Opts.BaseURL, c.Opts.Token) if err != nil { return fmt.Errorf("failed to create Codesphere client: %w", err) } c.Opts.Client = client - return c.RunSmoketest() + return c.RunSmoketest(cmd.Context()) } func AddSmoketestCmd(parent *cobra.Command, opts *util.GlobalOptions) { @@ -113,8 +114,11 @@ func AddSmoketestCmd(parent *cobra.Command, opts *util.GlobalOptions) { util.AddCmd(parent, c.cmd) } -func (c *SmoketestCodesphereCmd) RunSmoketest() (err error) { - ctx, cancel := context.WithTimeout(context.Background(), c.Opts.Timeout) +// RunSmoketest runs the selected smoke test steps. The passed context bounds +// the run in addition to the configured timeout, so callers that orchestrate +// several tests (see the test command) can cancel it. +func (c *SmoketestCodesphereCmd) RunSmoketest(ctx context.Context) (err error) { + ctx, cancel := context.WithTimeout(ctx, c.Opts.Timeout) defer cancel() availableStepsMap := make(map[string]teststeps.SmokeTestStep) diff --git a/cli/cmd/codesphere/smoketest_codesphere_test.go b/cli/cmd/codesphere/smoketest_codesphere_test.go index e740356e2..70bb7a7ab 100644 --- a/cli/cmd/codesphere/smoketest_codesphere_test.go +++ b/cli/cmd/codesphere/smoketest_codesphere_test.go @@ -4,6 +4,7 @@ package codesphere_test import ( + "context" "fmt" "strconv" "strings" @@ -124,7 +125,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { It("returns an error indicating no teams are available", func() { mockClient.EXPECT().ListTeams("").Return([]api.Team{}, nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("no teams available"))) }) }) @@ -138,7 +139,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { mockFullTestRun(mockClient, 99, 456, 789) - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(BeNil()) }) }) @@ -152,7 +153,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { mockFullTestRun(mockClient, 21, 456, 789) - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(BeNil()) }) }) @@ -164,7 +165,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { It("returns an error indicating no workspace plans are available", func() { mockClient.EXPECT().ListWorkspacePlans().Return([]api.WorkspacePlan{}, nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("no workspace plans available"))) }) }) @@ -176,13 +177,14 @@ var _ = Describe("SmoketestCodesphereCmd", func() { mockFullTestRun(mockClient, teamIdInt, 42, 789) - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(BeNil()) }) }) It("completes successfully with all steps", func() { mockFullTestRun(mockClient, teamIdInt, planIdInt, 789) - err := c.RunSmoketest() + + err := c.RunSmoketest(context.Background()) Expect(err).To(BeNil()) }) @@ -194,7 +196,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { (*string)(nil), // empty workspace ).Return(0, fmt.Errorf("create failed")).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("failed to create workspace"))) }) @@ -218,7 +220,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { workspaceID, ).Return(nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("failed to set environment variable"))) }) @@ -248,7 +250,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { workspaceId, ).Return(nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("failed to create ci.yml"))) }) @@ -291,7 +293,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { workspaceId, ).Return(nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("failed to sync landscape"))) }) @@ -340,7 +342,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { workspaceId, ).Return(nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("failed to start pipeline"))) }) @@ -394,7 +396,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { workspaceId, ).Return(nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("unexpected state"))) }) @@ -448,7 +450,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { workspaceId, ).Return(nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("unexpected state"))) }) @@ -501,7 +503,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { ).Return(nil).Once() opts.Timeout = 100 * time.Millisecond - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("timed out"))) Expect(err).To(MatchError(ContainSubstring("connection refused"))) }) @@ -558,7 +560,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { ).Return(nil).Once() opts.Timeout = 100 * time.Millisecond - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("timed out"))) }) @@ -614,7 +616,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { workspaceId, ).Return(fmt.Errorf("delete failed")).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(MatchError(ContainSubstring("failed to delete workspace"))) }) @@ -634,7 +636,7 @@ var _ = Describe("SmoketestCodesphereCmd", func() { "smoketest", ).Return(nil).Once() - err := c.RunSmoketest() + err := c.RunSmoketest(context.Background()) Expect(err).To(BeNil()) }) }) diff --git a/cli/cmd/codesphere/status_codesphere.go b/cli/cmd/codesphere/status_codesphere.go new file mode 100644 index 000000000..23e32d9bf --- /dev/null +++ b/cli/cmd/codesphere/status_codesphere.go @@ -0,0 +1,87 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package codesphere + +import ( + "fmt" + "time" + + csio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/cli/cmd/util" + "github.com/codesphere-cloud/oms/internal/codesphere" + "github.com/spf13/cobra" +) + +const ( + defaultStatusTimeout = 5 * time.Minute + statusPollInterval = 5 * time.Second +) + +// StatusCodesphereOpts configures the status report of a Codesphere installation. +type StatusCodesphereOpts struct { + BaseURL string + Token string + Wait bool + Timeout time.Duration + Client codesphere.Client +} + +// StatusCodesphereCmd represents the status codesphere command. +type StatusCodesphereCmd struct { + cmd *cobra.Command + Opts *StatusCodesphereOpts +} + +// RunE prints the status report and fails the command if the installation is not ready. +func (c *StatusCodesphereCmd) RunE(cmd *cobra.Command, _ []string) error { + client, err := codesphere.NewClient(c.Opts.BaseURL, c.Opts.Token) + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + + c.Opts.Client = client + + report := fetchStatus(cmd.Context(), c.Opts) + printStatus(cmd.OutOrStdout(), c.Opts.BaseURL, report) + + if !report.Ready { + return fmt.Errorf("codesphere installation is not ready") + } + + return nil +} + +// AddStatusCmd adds the status codesphere command to the given parent command. +func AddStatusCmd(parent *cobra.Command, _ *util.GlobalOptions) { + c := StatusCodesphereCmd{ + cmd: &cobra.Command{ + Use: "codesphere", + Short: "Check the status of a Codesphere installation", + Long: csio.Long(`Check whether a Codesphere installation is reachable and ready to use, + by querying the Codesphere API.`), + Example: util.FormatExamples("status codesphere", []csio.Example{ + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN", + Desc: "Check the status of a Codesphere installation", + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --wait", + Desc: "Block and retry until the Codesphere installation is ready", + }, + }), + }, + Opts: &StatusCodesphereOpts{}, + } + c.cmd.Flags().StringVar(&c.Opts.BaseURL, "baseurl", "", "Base URL of the Codesphere API") + c.cmd.Flags().StringVar(&c.Opts.Token, "token", "", "API token for authentication") + c.cmd.Flags().BoolVar(&c.Opts.Wait, "wait", false, "Block and retry until the installation is ready") + c.cmd.Flags().DurationVar(&c.Opts.Timeout, "timeout", defaultStatusTimeout, "Timeout when waiting for the installation to become ready") + + util.MarkFlagRequired(c.cmd, "baseurl") + util.MarkFlagRequired(c.cmd, "token") + + c.cmd.RunE = c.RunE + + util.AddCmd(parent, c.cmd) +} diff --git a/cli/cmd/codesphere/status_report.go b/cli/cmd/codesphere/status_report.go new file mode 100644 index 000000000..5ad8eb580 --- /dev/null +++ b/cli/cmd/codesphere/status_report.go @@ -0,0 +1,155 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package codesphere + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + "time" +) + +const ( + ansiReset = "\x1b[0m" + ansiBold = "\x1b[1m" + ansiCyan = "\x1b[36m" + ansiGreen = "\x1b[32m" + ansiRed = "\x1b[31m" +) + +// logo is a small ASCII mark printed next to the status report, neofetch-style. +var logo = []string{ + " â–„â–„â–„â–„â–„â–„â–„â–„â–„â–„â–„â–„ ", + " ▄█████████████████▄ ", + " ▄███▀▀▀ ▀▀▀███▄ ", + "███ ████", + "██ â–„â–„â–„â–„â–„â–„â–„â–„â–„ ███", + "██ ███████████ ███", + "██ ███████████ ███", + "██ ▀▀▀▀▀▀▀▀▀ ███", + "████ ████", + " ▀███▄▄▄ ▄▄▄███▀ ", + " ▀██████████████████▀ ", + " ▀▀▀▀▀▀▀▀▀▀▀▀ ", +} + +type statusReport struct { + Ready bool + Latency time.Duration + Teams int + Plans int + Attempts int + Err error +} + +// fetchStatus pings the Codesphere API with a cheap, side-effect-free call +// (ListWorkspacePlans) to determine readiness. With Wait set, it retries on +// failure until the installation becomes ready or opts.Timeout elapses. +func fetchStatus(ctx context.Context, opts *StatusCodesphereOpts) *statusReport { + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + report := &statusReport{} + for { + report.Attempts++ + + start := time.Now() + plans, err := opts.Client.ListWorkspacePlans() + report.Latency = time.Since(start) + + if err == nil { + report.Ready = true + + report.Plans = len(plans) + if teams, terr := opts.Client.ListTeams(""); terr == nil { + report.Teams = len(teams) + } + + return report + } + + report.Err = err + + if !opts.Wait { + return report + } + + select { + case <-ctx.Done(): + return report + case <-time.After(statusPollInterval): + } + } +} + +// printStatus renders a neofetch-style report: a small ASCII logo alongside +// key/value status lines. +func printStatus(w io.Writer, baseURL string, r *statusReport) { + host := baseURL + if u, err := url.Parse(baseURL); err == nil && u.Host != "" { + host = u.Host + } + + statusColor, statusText := ansiGreen, "Ready" + if !r.Ready { + statusColor, statusText = ansiRed, "Not Ready" + } + + header := fmt.Sprintf("%s%scodesphere%s@%s", ansiBold, ansiCyan, ansiReset, host) + rule := strings.Repeat("-", len("codesphere@")+len(host)) + + lines := []string{ + header, + rule, + fmt.Sprintf("%sStatus%s: %s%s%s", ansiBold, ansiReset, statusColor, statusText, ansiReset), + fmt.Sprintf("%sLatency%s: %s", ansiBold, ansiReset, r.Latency.Round(time.Millisecond)), + } + if r.Ready { + lines = append(lines, + fmt.Sprintf("%sTeams%s: %d", ansiBold, ansiReset, r.Teams), + fmt.Sprintf("%sPlans%s: %d", ansiBold, ansiReset, r.Plans), + ) + } else { + lines = append(lines, fmt.Sprintf("%sError%s: %s", ansiBold, ansiReset, r.Err)) + } + + if r.Attempts > 1 { + lines = append(lines, fmt.Sprintf("%sAttempts%s: %d", ansiBold, ansiReset, r.Attempts)) + } + + rows := len(logo) + if len(lines) > rows { + rows = len(lines) + } + + // Pad the logo to a fixed width so the status lines form a straight column. + logoWidth := 0 + for _, l := range logo { + if n := len([]rune(l)); n > logoWidth { + logoWidth = n + } + } + + _, _ = fmt.Fprintln(w) + + for i := 0; i < rows; i++ { + logoLine := "" + if i < len(logo) { + logoLine = logo[i] + } + + logoLine += strings.Repeat(" ", logoWidth-len([]rune(logoLine))) + + statLine := "" + if i < len(lines) { + statLine = lines[i] + } + + _, _ = fmt.Fprintf(w, " %s%s%s %s\n", ansiCyan, logoLine, ansiReset, statLine) + } + + _, _ = fmt.Fprintln(w) +} diff --git a/cli/cmd/codesphere/test_codesphere.go b/cli/cmd/codesphere/test_codesphere.go new file mode 100644 index 000000000..d4394ac59 --- /dev/null +++ b/cli/cmd/codesphere/test_codesphere.go @@ -0,0 +1,249 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package codesphere + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + csio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/cli/cmd/util" + "github.com/codesphere-cloud/oms/internal/codesphere" + "github.com/codesphere-cloud/oms/internal/codesphere/testplan" + "github.com/codesphere-cloud/oms/internal/codesphere/teststeps" + "github.com/spf13/cobra" +) + +const ( + // defaultTestTimeout bounds the whole playlist, not a single test. + defaultTestTimeout = 20 * time.Minute + // DefaultPlaylist is run when neither --playlist nor --tests is given. + DefaultPlaylist = "default" +) + +// Names of the tests that can be part of a playlist. +const ( + StatusTestName = "status" + SmoketestTestName = "smoketest" +) + +// TestCodesphereOpts configures a test run against a Codesphere installation. +type TestCodesphereOpts struct { + BaseURL string + Token string + TeamID string + PlanID string + Profile string + Playlist string + Tests []string + Wait bool + WaitTimeout time.Duration + Timeout time.Duration + FailFast bool + Quiet bool + Client codesphere.Client +} + +// TestCodesphereCmd represents the test codesphere command. +type TestCodesphereCmd struct { + cmd *cobra.Command + Opts *TestCodesphereOpts +} + +// Registry returns the tests that can run against a Codesphere installation, +// together with the playlists that group them. The tests close over opts, so +// the registry has to be built after the flags are parsed and the client is +// set. Building it with zero options is safe as long as no test is run, which +// is what the test list command does. +func Registry(opts *TestCodesphereOpts) *testplan.Registry { + statusTest := &testplan.Func{ + TestName: StatusTestName, + Desc: "Report the state of the installation and verify the API answers", + Fn: func(ctx context.Context, out io.Writer) error { + waitTimeout := opts.WaitTimeout + if waitTimeout <= 0 { + waitTimeout = defaultStatusTimeout + } + + statusOpts := &StatusCodesphereOpts{ + BaseURL: opts.BaseURL, + Token: opts.Token, + Wait: opts.Wait, + Timeout: waitTimeout, + Client: opts.Client, + } + + report := fetchStatus(ctx, statusOpts) + printStatus(out, opts.BaseURL, report) + + if !report.Ready { + if report.Err != nil { + return fmt.Errorf("codesphere installation is not ready: %w", report.Err) + } + + return fmt.Errorf("codesphere installation is not ready") + } + + return nil + }, + } + + smoketest := &testplan.Func{ + TestName: SmoketestTestName, + Desc: "Create a workspace, deploy a sample app in it and clean up afterwards", + Fn: func(ctx context.Context, _ io.Writer) error { + c := SmoketestCodesphereCmd{ + Opts: &teststeps.SmoketestCodesphereOpts{ + BaseURL: opts.BaseURL, + Token: opts.Token, + TeamID: opts.TeamID, + PlanID: opts.PlanID, + Profile: opts.Profile, + Quiet: opts.Quiet, + Timeout: opts.Timeout, + Client: opts.Client, + }, + } + + return c.RunSmoketest(ctx) + }, + } + + registry := testplan.NewRegistry(statusTest, smoketest) + registry.AddPlaylist(testplan.Playlist{ + Name: DefaultPlaylist, + Description: "Verify the installation is up and can run a workspace", + Tests: []string{StatusTestName, SmoketestTestName}, + }) + registry.AddPlaylist(testplan.Playlist{ + Name: "readiness", + Description: "Only check that the installation is reachable and ready", + Tests: []string{StatusTestName}, + }) + + return registry +} + +// selectTests resolves the requested tests. An explicit --tests selection wins +// over --playlist, so a playlist default doesn't have to be unset first. +func (c *TestCodesphereCmd) selectTests() ([]testplan.Test, error) { + registry := Registry(c.Opts) + + if len(c.Opts.Tests) > 0 { + tests, err := registry.Select(c.Opts.Tests) + if err != nil { + return nil, fmt.Errorf("failed to select tests: %w", err) + } + + return tests, nil + } + + tests, err := registry.SelectPlaylist(c.Opts.Playlist) + if err != nil { + return nil, fmt.Errorf("failed to select playlist: %w", err) + } + + return tests, nil +} + +// RunE runs the selected tests and fails the command if any of them failed. +func (c *TestCodesphereCmd) RunE(cmd *cobra.Command, _ []string) error { + tests, err := c.selectTests() + if err != nil { + return err + } + + client, err := codesphere.NewClient(c.Opts.BaseURL, c.Opts.Token) + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + + c.Opts.Client = client + + ctx, cancel := context.WithTimeout(cmd.Context(), c.Opts.Timeout) + defer cancel() + + out := cmd.OutOrStdout() + runner := &testplan.Runner{ + Out: out, + FailFast: c.Opts.FailFast, + Quiet: c.Opts.Quiet, + } + + results := runner.Run(ctx, tests) + testplan.Summarize(out, results) + + if err := testplan.Err(results); err != nil { + return fmt.Errorf("test run failed: %w", err) + } + + return nil +} + +// AddTestCmd adds the test codesphere command to the given parent command. +func AddTestCmd(parent *cobra.Command, _ *util.GlobalOptions) { + registry := Registry(&TestCodesphereOpts{}) + + c := TestCodesphereCmd{ + cmd: &cobra.Command{ + Use: "codesphere", + Short: "Run a playlist of tests against a Codesphere installation", + Long: csio.Long(`Run a playlist of tests against a Codesphere installation. + + A playlist is an ordered selection of tests, for example a status report + followed by a smoke test. Every test is run even if an earlier one failed, + unless --fail-fast is set, and the results are summarized at the end. + + Run 'oms test list' to see the available tests and playlists.`), + Example: util.FormatExamples("test codesphere", []csio.Example{ + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN", + Desc: fmt.Sprintf("Run the %q playlist against a Codesphere installation", DefaultPlaylist), + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --playlist readiness", + Desc: "Run a specific playlist", + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --tests status,smoketest", + Desc: "Run a specific list of tests, in the given order", + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --wait", + Desc: "Wait for the installation to become ready before running the remaining tests", + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --fail-fast", + Desc: "Stop at the first failing test instead of running the whole playlist", + }, + }), + }, + Opts: &TestCodesphereOpts{}, + } + + c.cmd.Flags().StringVar(&c.Opts.BaseURL, "baseurl", "", "Base URL of the Codesphere API") + c.cmd.Flags().StringVar(&c.Opts.Token, "token", "", "API token for authentication") + c.cmd.Flags().StringVar(&c.Opts.TeamID, "team-id", "", "Team ID to run tests in") + c.cmd.Flags().StringVar(&c.Opts.PlanID, "plan-id", "", "Plan ID to use for workspaces created by tests") + c.cmd.Flags().StringVar(&c.Opts.Profile, "profile", defaultProfile, "CI profile to use for landscape and pipeline") + c.cmd.Flags().StringVar(&c.Opts.Playlist, "playlist", DefaultPlaylist, + fmt.Sprintf("Playlist of tests to run (%s)", strings.Join(registry.PlaylistNames(), ","))) + c.cmd.Flags().StringSliceVar(&c.Opts.Tests, "tests", []string{}, + fmt.Sprintf("Comma-separated list of tests to run, in the given order (%s). Takes precedence over --playlist.", strings.Join(registry.TestNames(), ","))) + c.cmd.Flags().BoolVar(&c.Opts.Wait, "wait", false, "Wait for the installation to become ready during the status test") + c.cmd.Flags().DurationVar(&c.Opts.WaitTimeout, "wait-timeout", defaultStatusTimeout, "Timeout when waiting for the installation to become ready") + c.cmd.Flags().DurationVar(&c.Opts.Timeout, "timeout", defaultTestTimeout, "Timeout for the entire test run") + c.cmd.Flags().BoolVar(&c.Opts.FailFast, "fail-fast", false, "Skip the remaining tests after the first failure") + c.cmd.Flags().BoolVarP(&c.Opts.Quiet, "quiet", "q", false, "Suppress progress logging") + + util.MarkFlagRequired(c.cmd, "baseurl") + util.MarkFlagRequired(c.cmd, "token") + + c.cmd.RunE = c.RunE + + util.AddCmd(parent, c.cmd) +} diff --git a/cli/cmd/codesphere/test_codesphere_test.go b/cli/cmd/codesphere/test_codesphere_test.go new file mode 100644 index 000000000..0c88515aa --- /dev/null +++ b/cli/cmd/codesphere/test_codesphere_test.go @@ -0,0 +1,144 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package codesphere_test + +import ( + "bytes" + "context" + "fmt" + "time" + + "github.com/codesphere-cloud/cs-go/api" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/cli/cmd/codesphere" + intcs "github.com/codesphere-cloud/oms/internal/codesphere" + "github.com/codesphere-cloud/oms/internal/codesphere/testplan" +) + +var _ = Describe("TestCodesphereCmd", func() { + var ( + mockClient *intcs.MockClient + opts *codesphere.TestCodesphereOpts + out *bytes.Buffer + runner *testplan.Runner + ) + + BeforeEach(func() { + mockClient = intcs.NewMockClient(GinkgoT()) + out = &bytes.Buffer{} + opts = &codesphere.TestCodesphereOpts{ + BaseURL: "https://test.codesphere.com/api", + Token: "test-token", + TeamID: "123", + PlanID: "456", + Profile: "ci.yml", + Quiet: true, // Suppress log output in tests + Timeout: time.Minute, + WaitTimeout: time.Minute, + Client: mockClient, + } + runner = &testplan.Runner{Out: out, Quiet: true} + }) + + AfterEach(func() { + mockClient.AssertExpectations(GinkgoT()) + }) + + expectHealthyStatus := func() { + mockClient.EXPECT().ListWorkspacePlans().Return([]api.WorkspacePlan{{Id: 456, Title: "small"}}, nil).Once() + mockClient.EXPECT().ListTeams("").Return([]api.Team{{Id: 123, Name: "team"}}, nil).Once() + } + + Describe("Registry", func() { + It("offers the status and smoketest tests", func() { + registry := codesphere.Registry(opts) + + Expect(registry.TestNames()).To(ContainElements( + codesphere.StatusTestName, + codesphere.SmoketestTestName, + )) + }) + + It("runs the status test before the smoketest in the default playlist", func() { + tests, err := codesphere.Registry(opts).SelectPlaylist(codesphere.DefaultPlaylist) + + Expect(err).NotTo(HaveOccurred()) + Expect(tests).To(HaveLen(2)) + Expect(tests[0].Name()).To(Equal(codesphere.StatusTestName)) + Expect(tests[1].Name()).To(Equal(codesphere.SmoketestTestName)) + }) + + It("offers a readiness playlist that only checks the status", func() { + tests, err := codesphere.Registry(opts).SelectPlaylist("readiness") + + Expect(err).NotTo(HaveOccurred()) + Expect(tests).To(HaveLen(1)) + Expect(tests[0].Name()).To(Equal(codesphere.StatusTestName)) + }) + }) + + Describe("status test", func() { + var tests []testplan.Test + + JustBeforeEach(func() { + var err error + + tests, err = codesphere.Registry(opts).Select([]string{codesphere.StatusTestName}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("passes and reports the installation state if the API answers", func() { + expectHealthyStatus() + + results := runner.Run(context.Background(), tests) + + Expect(testplan.Err(results)).To(BeNil()) + Expect(out.String()).To(ContainSubstring("test.codesphere.com")) + Expect(out.String()).To(ContainSubstring("Ready")) + }) + + It("fails if the installation is not reachable", func() { + mockClient.EXPECT().ListWorkspacePlans().Return(nil, fmt.Errorf("connection refused")).Once() + + results := runner.Run(context.Background(), tests) + + Expect(results[0].Status).To(Equal(testplan.StatusFailed)) + Expect(results[0].Err).To(MatchError(ContainSubstring("not ready"))) + Expect(results[0].Err).To(MatchError(ContainSubstring("connection refused"))) + }) + }) + + Describe("default playlist", func() { + It("passes if the installation is ready and the smoketest succeeds", func() { + expectHealthyStatus() + mockFullTestRun(mockClient, 123, 456, 789) + + tests, err := codesphere.Registry(opts).SelectPlaylist(codesphere.DefaultPlaylist) + Expect(err).NotTo(HaveOccurred()) + + results := runner.Run(context.Background(), tests) + + Expect(testplan.Err(results)).To(BeNil()) + Expect(results).To(HaveLen(2)) + }) + + It("skips the smoketest if the status test fails with fail-fast", func() { + mockClient.EXPECT().ListWorkspacePlans().Return(nil, fmt.Errorf("connection refused")).Once() + + runner.FailFast = true + + tests, err := codesphere.Registry(opts).SelectPlaylist(codesphere.DefaultPlaylist) + Expect(err).NotTo(HaveOccurred()) + + results := runner.Run(context.Background(), tests) + + Expect(results[0].Status).To(Equal(testplan.StatusFailed)) + Expect(results[1].Name).To(Equal(codesphere.SmoketestTestName)) + Expect(results[1].Status).To(Equal(testplan.StatusSkipped)) + Expect(testplan.Err(results)).To(MatchError(ContainSubstring("status"))) + }) + }) +}) diff --git a/cli/cmd/copy.go b/cli/cmd/copy.go new file mode 100644 index 000000000..34fcf2475 --- /dev/null +++ b/cli/cmd/copy.go @@ -0,0 +1,27 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + csio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/cli/cmd/util" + "github.com/spf13/cobra" +) + +// CopyCmd represents the copy command. +type CopyCmd struct { + cmd *cobra.Command +} + +// AddCopyCmd adds the copy command group to the root command. +func AddCopyCmd(rootCmd *cobra.Command, opts *util.GlobalOptions) { + copyCmd := CopyCmd{cmd: &cobra.Command{ + Use: "copy", + Short: "Copy resources between locations", + Long: csio.Long(`Copy resources managed by OMS between locations, + e.g. package container images and OCI Helm charts between registries.`), + }} + util.AddCmd(rootCmd, copyCmd.cmd) + AddCopyPackageCmd(copyCmd.cmd, opts) +} diff --git a/cli/cmd/copy_package.go b/cli/cmd/copy_package.go new file mode 100644 index 000000000..8d97ca177 --- /dev/null +++ b/cli/cmd/copy_package.go @@ -0,0 +1,176 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + + csio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/cli/cmd/util" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/prompt" + intutil "github.com/codesphere-cloud/oms/internal/util" + "github.com/google/go-containerregistry/pkg/logs" + "github.com/spf13/cobra" +) + +const defaultInstallerPackageArtifact = "installer-lite.tar.gz" + +// CopyPackageCmd represents the copy package command. +type CopyPackageCmd struct { + cmd *cobra.Command + Opts CopyPackageOpts + Env env.Env + FileWriter intutil.FileIO + Prompter prompt.Prompter +} + +// CopyPackageOpts holds the flags accepted by the copy package command. +type CopyPackageOpts struct { + *util.GlobalOptions + Package string + Version string + Hash string + Filename string + Dest string + Yes bool + Force bool + Insecure bool + Verbose bool + ShowArtifacts bool +} + +// RunE resolves the installer package and executes its artifact transfers. +func (c *CopyPackageCmd) RunE(cmd *cobra.Command, _ []string) error { + packagePath, err := c.resolvePackage(portal.NewPortalClient()) + if err != nil { + return err + } + + packageManager := installer.NewPackage(c.Env.GetOmsWorkdir(), packagePath) + + if c.Opts.Verbose { + logs.Debug.SetOutput(os.Stderr) + } + + return c.CopyPackage(cmd.Context(), packageManager, &installer.CraneArtifactCopier{ + Insecure: c.Opts.Insecure, + }) +} + +// AddCopyPackageCmd adds the package subcommand to the copy command. +func AddCopyPackageCmd(parent *cobra.Command, opts *util.GlobalOptions) { + c := &CopyPackageCmd{ + cmd: &cobra.Command{ + Use: "package", + Short: "Copy all images and Helm charts from an installer package", + Long: csio.Long(`Read all container images and OCI Helm charts from an installer package BOM + and copy them to another registry. + + Use --package for a local installer package or --version to download one + from the OMS portal. The source repository paths are preserved below --dest.`), + Args: cobra.NoArgs, + Example: util.FormatExamples("copy package", []csio.Example{ + {Cmd: "--package codesphere-v1.70.0-installer-lite.tar.gz --dest registry.example.com/mirror", Desc: "Copy artifacts from a local package"}, + {Cmd: "--version codesphere-v1.70.0 --dest registry.example.com/mirror --yes", Desc: "Download an upstream package and copy without prompting"}, + }), + }, + Opts: CopyPackageOpts{GlobalOptions: opts, Filename: defaultInstallerPackageArtifact}, + Env: env.NewEnv(), + FileWriter: intutil.NewFilesystemWriter(), + Prompter: prompt.NewPrompter(true), + } + c.cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if (c.Opts.Package == "") == (c.Opts.Version == "") { + return fmt.Errorf("exactly one of --package or --version must be specified") + } + + return nil + } + + flags := c.cmd.Flags() + flags.StringVarP(&c.Opts.Package, "package", "p", "", "Path to a local installer package") + flags.StringVarP(&c.Opts.Version, "version", "V", "", "Codesphere package version to download from the OMS portal") + flags.StringVarP(&c.Opts.Hash, "hash", "H", "", "Build hash used to disambiguate an upstream package version") + flags.StringVarP(&c.Opts.Filename, "file", "f", defaultInstallerPackageArtifact, "Installer artifact to download for an upstream package") + flags.StringVar(&c.Opts.Dest, "dest", "", "Destination registry or repository prefix") + flags.BoolVar(&c.Opts.Insecure, "insecure", false, "Allow image references to be fetched without TLS") + flags.BoolVarP(&c.Opts.Yes, "yes", "y", false, "Copy without prompting for confirmation") + flags.BoolVarP(&c.Opts.Verbose, "verbose", "v", false, "Enable debug logs") + flags.BoolVar(&c.Opts.Force, "force", false, "Re-extract the installer package") + flags.BoolVar(&c.Opts.ShowArtifacts, "show-artifacts", false, "Print the source and destination of every artifact to copy") + util.MarkFlagRequired(c.cmd, "dest") + + util.AddCmd(parent, c.cmd) + c.cmd.RunE = c.RunE +} + +// CopyPackage extracts the package, prints the complete transfer plan, asks +// for confirmation, and then copies each artifact. +func (c *CopyPackageCmd) CopyPackage(ctx context.Context, packageManager installer.PackageManager, copier installer.ArtifactCopier) error { + if err := packageManager.Extract(c.Opts.Force); err != nil { + return fmt.Errorf("failed to extract package: %w", err) + } + + artifacts, err := installer.ReadPackageArtifacts(packageManager.GetDependencyPath("bom.json"), c.Opts.Dest) + if err != nil { + return fmt.Errorf("failed to read package BOM: %w", err) + } + + if len(artifacts) == 0 { + return fmt.Errorf("package BOM contains no container images or OCI Helm charts") + } + + if c.Opts.ShowArtifacts { + log.Printf("Artifacts to copy (%d):", len(artifacts)) + for _, artifact := range artifacts { + log.Printf(" %s -> %s", artifact.Source, artifact.Destination) + } + } else { + log.Printf("Artifacts to copy: %d", len(artifacts)) + } + + if !c.Opts.Yes && !c.Prompter.Bool("Copy these artifacts?", false) { + return fmt.Errorf("transfer cancelled") + } + + log.Printf("Copying %d package artifacts...", len(artifacts)) + if err := installer.CopyPackageArtifacts(ctx, copier, artifacts); err != nil { + return fmt.Errorf("failed to copy package artifacts: %w", err) + } + + log.Printf("Successfully copied %d package artifacts to %s", len(artifacts), c.Opts.Dest) + + return nil +} + +func (c *CopyPackageCmd) resolvePackage(portalClient portal.Portal) (string, error) { + if c.Opts.Package != "" { + return c.Opts.Package, nil + } + + workdir := c.Env.GetOmsWorkdir() + if err := os.MkdirAll(workdir, 0755); err != nil { + return "", fmt.Errorf("failed to create OMS workdir: %w", err) + } + + build, err := portalClient.GetBuild(portal.CodesphereProduct, c.Opts.Version, c.Opts.Hash) + if err != nil { + return "", fmt.Errorf("failed to get upstream package: %w", err) + } + + destination := filepath.Join(workdir, build.BuildPackageFilename(c.Opts.Filename)) + + if err := portal.DownloadAndVerifyBuild(portalClient, c.FileWriter, portal.CodesphereProduct, build, c.Opts.Filename, destination, portal.DownloadOptions{}); err != nil { + return "", fmt.Errorf("failed to download upstream package: %w", err) + } + + return destination, nil +} diff --git a/cli/cmd/copy_package_test.go b/cli/cmd/copy_package_test.go new file mode 100644 index 000000000..fcee3b377 --- /dev/null +++ b/cli/cmd/copy_package_test.go @@ -0,0 +1,164 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/prompt" + intutil "github.com/codesphere-cloud/oms/internal/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +type commandRecordingCopier struct { + copies []installer.PackageArtifact +} + +func (c *commandRecordingCopier) Copy(_ context.Context, source, destination string) error { + c.copies = append(c.copies, installer.PackageArtifact{Source: source, Destination: destination}) + return nil +} + +var _ = Describe("CopyPackageCmd", func() { + var ( + packageManager *installer.MockPackageManager + bomPath string + ) + + BeforeEach(func() { + packageManager = installer.NewMockPackageManager(GinkgoT()) + bomPath = filepath.Join(GinkgoT().TempDir(), "bom.json") + Expect(os.WriteFile(bomPath, []byte(`{ + "components": {"codesphere": {"containerImages": {"api": "ghcr.io/codesphere/api:v1"}}} + }`), 0644)).To(Succeed()) + }) + + preparePackageManager := func() { + packageManager.EXPECT().Extract(false).Return(nil) + packageManager.EXPECT().GetDependencyPath("bom.json").Return(bomPath) + } + + It("asks for confirmation before copying", func() { + preparePackageManager() + + prompter := prompt.NewMockPrompter(GinkgoT()) + prompter.EXPECT().Bool("Copy these artifacts?", false).Return(true) + + copier := &commandRecordingCopier{} + command := &CopyPackageCmd{ + Opts: CopyPackageOpts{Dest: "registry.example.com/mirror"}, + Prompter: prompter, + } + + Expect(command.CopyPackage(context.Background(), packageManager, copier)).To(Succeed()) + Expect(copier.copies).To(Equal([]installer.PackageArtifact{{ + Source: "ghcr.io/codesphere/api:v1", + Destination: "registry.example.com/mirror/codesphere/api:v1", + }})) + }) + + It("cancels without copying when confirmation is declined", func() { + preparePackageManager() + + prompter := prompt.NewMockPrompter(GinkgoT()) + prompter.EXPECT().Bool("Copy these artifacts?", false).Return(false) + + copier := &commandRecordingCopier{} + command := &CopyPackageCmd{ + Opts: CopyPackageOpts{Dest: "registry.example.com/mirror"}, + Prompter: prompter, + } + + err := command.CopyPackage(context.Background(), packageManager, copier) + Expect(err).To(MatchError("transfer cancelled")) + Expect(copier.copies).To(BeEmpty()) + }) + + It("skips confirmation with --yes", func() { + preparePackageManager() + + copier := &commandRecordingCopier{} + command := &CopyPackageCmd{ + Opts: CopyPackageOpts{Dest: "registry.example.com/mirror", Yes: true}, + Prompter: prompt.NewMockPrompter(GinkgoT()), + } + + Expect(command.CopyPackage(context.Background(), packageManager, copier)).To(Succeed()) + Expect(copier.copies).To(HaveLen(1)) + }) + + It("registers the command and validates package source flags", func() { + root := GetRootCmd() + copyPackage, _, err := root.Find([]string{"copy", "package"}) + Expect(err).NotTo(HaveOccurred()) + Expect(copyPackage).NotTo(BeNil()) + Expect(copyPackage.Flags().Lookup("dest")).NotTo(BeNil()) + Expect(copyPackage.Flags().Lookup("yes")).NotTo(BeNil()) + + root.SetArgs([]string{"copy", "package", "--dest", "registry.example.com", "--yes"}) + Expect(root.Execute()).To(MatchError("exactly one of --package or --version must be specified")) + }) + + Describe("resolvePackage with --version", func() { + var ( + mockEnv *env.MockEnv + mockPortal *portal.MockPortal + mockFileWriter *intutil.MockFileIO + workdir string + build portal.Build + command *CopyPackageCmd + ) + + BeforeEach(func() { + workdir = GinkgoT().TempDir() + mockEnv = env.NewMockEnv(GinkgoT()) + mockEnv.EXPECT().GetOmsWorkdir().Return(workdir) + + mockPortal = portal.NewMockPortal(GinkgoT()) + mockFileWriter = intutil.NewMockFileIO(GinkgoT()) + + build = portal.Build{ + Version: "codesphere-v1.70.0", + Hash: "abc1234567", + Artifacts: []portal.Artifact{{Filename: defaultInstallerPackageArtifact}}, + } + + command = &CopyPackageCmd{ + Opts: CopyPackageOpts{Version: build.Version, Filename: defaultInstallerPackageArtifact}, + Env: mockEnv, + FileWriter: mockFileWriter, + } + }) + + It("downloads and verifies the upstream package", func() { + mockPortal.EXPECT().GetBuild(portal.CodesphereProduct, build.Version, "").Return(build, nil) + + destination := filepath.Join(workdir, build.BuildPackageFilename(defaultInstallerPackageArtifact)) + fakeFile := os.NewFile(uintptr(0), destination) + mockFileWriter.EXPECT().Create(destination).Return(fakeFile, nil) + mockFileWriter.EXPECT().Open(destination).Return(fakeFile, nil) + mockPortal.EXPECT().DownloadBuildArtifact(portal.CodesphereProduct, mock.Anything, mock.Anything, 0, false).Return(nil) + mockPortal.EXPECT().VerifyBuildArtifactDownload(mock.Anything, mock.Anything).Return(nil) + + path, err := command.resolvePackage(mockPortal) + Expect(err).NotTo(HaveOccurred()) + Expect(path).To(Equal(destination)) + }) + + It("returns an error when the upstream package cannot be found", func() { + mockPortal.EXPECT().GetBuild(portal.CodesphereProduct, build.Version, "").Return(portal.Build{}, fmt.Errorf("build not found")) + + _, err := command.resolvePackage(mockPortal) + Expect(err).To(MatchError(ContainSubstring("failed to get upstream package"))) + }) + }) +}) diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go index fa671a2bc..eec532c75 100644 --- a/cli/cmd/download_package.go +++ b/cli/cmd/download_package.go @@ -96,43 +96,13 @@ func AddDownloadPackageCmd(download *cobra.Command, opts *util.GlobalOptions) { } func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build, filename string) error { - download, err := build.GetBuildForDownload(filename) - if err != nil { - return fmt.Errorf("failed to find artifact in package: %w", err) - } - fullFilename := build.BuildPackageFilename(filename) - out, err := c.FileWriter.OpenAppend(fullFilename) - if err != nil { - out, err = c.FileWriter.Create(fullFilename) - if err != nil { - return fmt.Errorf("failed to create file %s: %w", fullFilename, err) - } - } - defer intutil.CloseFileIgnoreError(out) - - // get already downloaded file size of fullFilename - fileSize := 0 - fileInfo, err := out.Stat() - if err == nil { - fileSize = int(fileInfo.Size()) - } - err = p.DownloadBuildArtifact("codesphere", download, out, fileSize, c.Opts.Quiet) - if err != nil { - return fmt.Errorf("failed to download build: %w", err) + if err := portal.DownloadAndVerifyBuild(p, c.FileWriter, portal.CodesphereProduct, build, filename, fullFilename, portal.DownloadOptions{ + Resume: true, + Quiet: c.Opts.Quiet, + }); err != nil { + return fmt.Errorf("failed to download and verify build: %w", err) } - - verifyFile, err := c.FileWriter.Open(fullFilename) - if err != nil { - return err - } - defer intutil.CloseFileIgnoreError(verifyFile) - - err = p.VerifyBuildArtifactDownload(verifyFile, download) - if err != nil { - return fmt.Errorf("failed to verify artifact: %w", err) - } - return nil } diff --git a/cli/cmd/download_package_test.go b/cli/cmd/download_package_test.go index c38fe1dc8..93c388627 100644 --- a/cli/cmd/download_package_test.go +++ b/cli/cmd/download_package_test.go @@ -242,7 +242,7 @@ var _ = Describe("DownloadPackages", func() { Context("File doesn't exist in build", func() { It("Returns an error", func() { err := c.DownloadBuild(mockPortal, build, "installer-lite.tar.gz") - Expect(err).To(MatchError("failed to find artifact in package: artifact not found: installer-lite.tar.gz")) + Expect(err).To(MatchError("failed to download and verify build: failed to find artifact in package: artifact not found: installer-lite.tar.gz")) }) }) }) diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 202f054be..b6fe9cbec 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -27,6 +27,8 @@ type InitInstallConfigOpts struct { ConfigFile string VaultFile string + VaultType string + AgeKey string Profile string AnsibleInventoryFile string @@ -108,7 +110,10 @@ type InitInstallConfigOpts struct { } func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { - icg := installer.NewInstallConfigManager() + icg, err := installer.NewInstallConfigManager(c.Opts.VaultType, c.Opts.AgeKey) + if err != nil { + return fmt.Errorf("failed to initialize config manager: %w", err) + } return c.InitInstallConfig(icg) } @@ -150,6 +155,8 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *util.GlobalOptions) { c.cmd.Flags().StringVarP(&c.Opts.ConfigFile, "config", "c", "config.yaml", "Output file path for config.yaml") c.cmd.Flags().StringVar(&c.Opts.VaultFile, "vault", "prod.vault.yaml", "Output file path for prod.vault.yaml") + c.cmd.Flags().StringVar(&c.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") + c.cmd.Flags().StringVar(&c.Opts.AgeKey, "age-key", "", "Path to the age private key (required for sops unless SOPS_AGE_KEY or SOPS_AGE_KEY_FILE is set)") c.cmd.Flags().StringVar(&c.Opts.Profile, "profile", "", "Use a predefined configuration profile (dev, production, minimal)") c.cmd.Flags().StringVar(&c.Opts.AnsibleInventoryFile, "ansible-inventory", "", "Path to Ansible inventory file to import host information from") @@ -177,6 +184,8 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *util.GlobalOptions) { // K8s c.cmd.Flags().BoolVar(&c.Opts.KubernetesManagedByCodesphere, "k8s-managed", true, "Use Codesphere-managed Kubernetes") c.cmd.Flags().StringSliceVar(&c.Opts.KubernetesControlPlanes, "k8s-control-plane", []string{}, "K8s control plane IPs (comma-separated)") + c.cmd.Flags().StringVar(&c.Opts.KubernetesPodCIDR, "k8s-pod-cidr", "", "Pod CIDR (required when --k8s-managed=false)") + c.cmd.Flags().StringVar(&c.Opts.KubernetesServiceCIDR, "k8s-service-cidr", "", "Service CIDR (required when --k8s-managed=false)") // Ceph c.cmd.Flags().StringVar(&c.Opts.CephCsiKubeletDir, "ceph-csi-kubelet-dir", "", "Directory of kubelet for ceph csi. Required for some cloud providers") @@ -261,7 +270,9 @@ func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigMana return fmt.Errorf("failed to write config file: %w", err) } - if err := icg.WriteUnencryptedVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { + // The freshly generated vault is SOPS-encrypted automatically with the + // configured age key (--age-key or SOPS_AGE_KEY[_FILE]). + if err := icg.WriteVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } @@ -297,16 +308,8 @@ func (c *InitInstallConfigCmd) printSuccessMessage(warningCount int) { log.Println(strings.Repeat("=", 70)) log.Println("\nIMPORTANT: Keys and certificates have been generated and embedded in the vault file.") - log.Println(" Keep the vault file secure and encrypt it with SOPS before storing.") - - log.Println("\nNext steps:") - log.Println("1. Review the generated config.yaml and prod.vault.yaml") - log.Println("2. Install SOPS and Age: brew install sops age") - log.Println("3. Generate an Age keypair: age-keygen -o age_key.txt") - log.Println("4. Encrypt the vault file:") - log.Printf(" age-keygen -y age_key.txt # Get public key\n") - log.Printf(" sops --encrypt --age --in-place %s\n", c.Opts.VaultFile) - log.Println("5. Run the Codesphere installer with these configuration files") + log.Println(" The vault file has been encrypted with SOPS automatically.") + log.Println(" Keep the vault file and its decryption key secure.") log.Println() } @@ -420,6 +423,9 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va } // Kubernetes settings + if c.cmd != nil && c.cmd.Flags().Changed("k8s-managed") { + config.Kubernetes.ManagedByCodesphere = c.Opts.KubernetesManagedByCodesphere + } if c.Opts.KubernetesAPIServerHost != "" { config.Kubernetes.APIServerHost = c.Opts.KubernetesAPIServerHost } @@ -466,18 +472,18 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va // MetalLB settings if c.Opts.MetalLBEnabled { - if config.MetalLB == nil { - config.MetalLB = &files.MetalLBConfig{ + if config.Cluster.MetalLB == nil { + config.Cluster.MetalLB = &files.MetalLBConfig{ Enabled: c.Opts.MetalLBEnabled, Pools: []files.MetalLBPoolDef{}, } } else { - config.MetalLB.Enabled = c.Opts.MetalLBEnabled - config.MetalLB.Pools = []files.MetalLBPoolDef{} + config.Cluster.MetalLB.Enabled = c.Opts.MetalLBEnabled + config.Cluster.MetalLB.Pools = []files.MetalLBPoolDef{} } for _, pool := range c.Opts.MetalLBPools { - config.MetalLB.Pools = append(config.MetalLB.Pools, files.MetalLBPoolDef(pool)) + config.Cluster.MetalLB.Pools = append(config.Cluster.MetalLB.Pools, files.MetalLBPoolDef(pool)) } } diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go index 1be476802..0a90eca47 100644 --- a/cli/cmd/init_install_config_interactive_test.go +++ b/cli/cmd/init_install_config_interactive_test.go @@ -5,12 +5,16 @@ package cmd import ( "os" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/spf13/cobra" "github.com/codesphere-cloud/oms/cli/cmd/util" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/prompt" intutil "github.com/codesphere-cloud/oms/internal/util" . "github.com/codesphere-cloud/oms/internal/util/testing" ) @@ -18,7 +22,7 @@ import ( var _ = Describe("Interactive profile usage", func() { Context("when using profile with interactive mode", func() { It("should use profile values as defaults", func() { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() // Apply dev profile first (like the command does) err := icg.ApplyProfile("dev") @@ -65,7 +69,7 @@ var _ = Describe("Interactive profile usage", func() { }) It("should allow non-interactive collection to use profile defaults", func() { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() // Apply dev profile err := icg.ApplyProfile("dev") @@ -74,7 +78,7 @@ var _ = Describe("Interactive profile usage", func() { // In non-interactive mode, CollectInteractively would use defaults // We simulate this by checking that the prompter returns defaults // when interactive=false - prompter := installer.NewPrompter(false) + prompter := prompt.NewPrompter(false) // Test that prompter returns defaults when not interactive Expect(prompter.String("Test", "default-value")).To(Equal("default-value")) @@ -85,42 +89,32 @@ var _ = Describe("Interactive profile usage", func() { }) It("should generate valid config files with profile", func() { - configFile, err := os.CreateTemp("", "config-*.yaml") - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = os.Remove(configFile.Name()) }() - err = configFile.Close() - Expect(err).NotTo(HaveOccurred()) - - vaultFile, err := os.CreateTemp("", "vault-*.yaml") - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = os.Remove(vaultFile.Name()) }() - err = vaultFile.Close() - Expect(err).NotTo(HaveOccurred()) + configPath, vaultPath := newTempConfigVaultPair() c := &InitInstallConfigCmd{ Opts: &InitInstallConfigOpts{ GlobalOptions: &util.GlobalOptions{}, - ConfigFile: configFile.Name(), - VaultFile: vaultFile.Name(), + ConfigFile: configPath, + VaultFile: vaultPath, Profile: "dev", Interactive: false, // Non-interactive to avoid stdin issues }, FileWriter: intutil.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() - err = c.InitInstallConfig(icg) + icg := newPlainInstallConfigManager() + err := c.InitInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) // Verify files were created - _, err = os.Stat(configFile.Name()) + _, err = os.Stat(configPath) Expect(err).NotTo(HaveOccurred()) - _, err = os.Stat(vaultFile.Name()) + _, err = os.Stat(vaultPath) Expect(err).NotTo(HaveOccurred()) // Verify config content - err = icg.LoadInstallConfigFromFile(configFile.Name()) + err = icg.LoadInstallConfigFromFile(configPath) Expect(err).NotTo(HaveOccurred()) config := icg.GetInstallConfig() @@ -131,7 +125,7 @@ var _ = Describe("Interactive profile usage", func() { Context("when using production profile", func() { It("should set production-specific defaults", func() { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err := icg.ApplyProfile("production") Expect(err).NotTo(HaveOccurred()) @@ -153,7 +147,7 @@ var _ = Describe("Interactive profile usage", func() { mockIcg.EXPECT().ValidateInstallConfig().Return([]string{"configuration validation failed"}) mockIcg.EXPECT().GenerateSecrets().Return(nil) mockIcg.EXPECT().WriteInstallConfig("config.yaml", false).Return(nil) - mockIcg.EXPECT().WriteUnencryptedVault("vault.yaml", false).Return(nil) + mockIcg.EXPECT().WriteVault("vault.yaml", false).Return(nil) c := &InitInstallConfigCmd{ Opts: &InitInstallConfigOpts{ @@ -171,23 +165,13 @@ var _ = Describe("Interactive profile usage", func() { }) It("should still fail in non-interactive mode with validation errors", func() { - configFile, err := os.CreateTemp("", "config-*.yaml") - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = os.Remove(configFile.Name()) }() - err = configFile.Close() - Expect(err).NotTo(HaveOccurred()) - - vaultFile, err := os.CreateTemp("", "vault-*.yaml") - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = os.Remove(vaultFile.Name()) }() - err = vaultFile.Close() - Expect(err).NotTo(HaveOccurred()) + configPath, vaultPath := newTempConfigVaultPair() c := &InitInstallConfigCmd{ Opts: &InitInstallConfigOpts{ GlobalOptions: &util.GlobalOptions{}, - ConfigFile: configFile.Name(), - VaultFile: vaultFile.Name(), + ConfigFile: configPath, + VaultFile: vaultPath, Profile: "dev", Interactive: false, CodesphereOpenBaoUri: "not-a-valid-url", @@ -195,11 +179,131 @@ var _ = Describe("Interactive profile usage", func() { FileWriter: intutil.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() - err = c.InitInstallConfig(icg) + err := c.InitInstallConfig(icg) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("configuration validation failed")) }) }) }) + +var _ = Describe("Non-interactive install-config generation", func() { + DescribeTable("generates and validates the config for each profile", + func(profile string) { + configPath, vaultPath := newTempConfigVaultPair() + + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + GlobalOptions: &util.GlobalOptions{}, + ConfigFile: configPath, + VaultFile: vaultPath, + Profile: profile, + Interactive: false, + }, + FileWriter: intutil.NewFilesystemWriter(), + } + + icg := newPlainInstallConfigManager() + err := c.InitInstallConfig(icg) + Expect(err).NotTo(HaveOccurred()) + + // Both files must have been written. + _, err = os.Stat(configPath) + Expect(err).NotTo(HaveOccurred()) + _, err = os.Stat(vaultPath) + Expect(err).NotTo(HaveOccurred()) + + // The generated config must round-trip through the full load path and validate. + err = icg.LoadInstallConfigFromFile(configPath) + Expect(err).NotTo(HaveOccurred()) + Expect(icg.ValidateInstallConfig()).To(BeEmpty()) + + // The --validate CLI path must pass. The vault is skipped because the freshly + // generated vault is plaintext and LoadVaultFromFile requires SOPS encryption. + validateCmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + GlobalOptions: &util.GlobalOptions{}, + ConfigFile: configPath, + VaultFile: "", + ValidateOnly: true, + }, + FileWriter: intutil.NewFilesystemWriter(), + } + validateIcg := newPlainInstallConfigManager() + Expect(validateCmd.validateOnly(validateIcg)).To(Succeed()) + }, + Entry("dev profile", "dev"), + Entry("minimal profile", "minimal"), + Entry("production profile", "production"), + ) +}) + +// newTempConfigVaultPair returns paths to fresh config/vault files inside an +// auto-cleaned temp directory. +func newTempConfigVaultPair() (configPath, vaultPath string) { + dir := GinkgoT().TempDir() + return filepath.Join(dir, "config.yaml"), filepath.Join(dir, "prod.vault.yaml") +} + +var _ = Describe("Non-interactive Kubernetes CIDR flags", func() { + // buildCmd returns a command wired to a real cobra.Command registering the + // k8s flags (mirroring AddInitInstallConfigCmd), so Flags().Changed works. + buildCmd := func(opts *InitInstallConfigOpts) *InitInstallConfigCmd { + cmd := &cobra.Command{Use: "install-config"} + cmd.Flags().BoolVar(&opts.KubernetesManagedByCodesphere, "k8s-managed", true, "Use Codesphere-managed Kubernetes") + cmd.Flags().StringVar(&opts.KubernetesPodCIDR, "k8s-pod-cidr", "", "Pod CIDR (required when --k8s-managed=false)") + cmd.Flags().StringVar(&opts.KubernetesServiceCIDR, "k8s-service-cidr", "", "Service CIDR (required when --k8s-managed=false)") + + return &InitInstallConfigCmd{cmd: cmd, Opts: opts} + } + + It("applies --k8s-managed=false with both CIDRs", func() { + c := buildCmd(&InitInstallConfigOpts{GlobalOptions: &util.GlobalOptions{}}) + Expect(c.cmd.Flags().Set("k8s-managed", "false")).To(Succeed()) + Expect(c.cmd.Flags().Set("k8s-pod-cidr", "10.200.0.0/16")).To(Succeed()) + Expect(c.cmd.Flags().Set("k8s-service-cidr", "10.100.0.0/16")).To(Succeed()) + + root := files.NewRootConfig() + config := &root + c.updateConfigFromOpts(config, &files.InstallVault{}) + + Expect(config.Kubernetes.ManagedByCodesphere).To(BeFalse()) + Expect(config.Kubernetes.PodCIDR).To(Equal("10.200.0.0/16")) + Expect(config.Kubernetes.ServiceCIDR).To(Equal("10.100.0.0/16")) + }) + + It("does not override managed Kubernetes when --k8s-managed is not set", func() { + c := buildCmd(&InitInstallConfigOpts{GlobalOptions: &util.GlobalOptions{}}) + + // The flag is not passed, so its default (true) must not overwrite an + // existing value on the config, e.g. one loaded from disk. + root := files.NewRootConfig() + config := &root + config.Kubernetes.ManagedByCodesphere = false + + c.updateConfigFromOpts(config, &files.InstallVault{}) + + Expect(config.Kubernetes.ManagedByCodesphere).To(BeFalse()) + Expect(config.Kubernetes.PodCIDR).To(BeEmpty()) + Expect(config.Kubernetes.ServiceCIDR).To(BeEmpty()) + }) + + It("fails when --k8s-managed=false and the pod CIDR is missing", func() { + configPath, vaultPath := newTempConfigVaultPair() + opts := &InitInstallConfigOpts{ + GlobalOptions: &util.GlobalOptions{}, + ConfigFile: configPath, + VaultFile: vaultPath, + Profile: "dev", + Interactive: false, + } + c := buildCmd(opts) + Expect(c.cmd.Flags().Set("k8s-managed", "false")).To(Succeed()) + + icg := newPlainInstallConfigManager() + err := c.InitInstallConfig(icg) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("pod CIDR is required")) + }) +}) diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go index 4512acbef..435ecffbc 100644 --- a/cli/cmd/init_install_config_test.go +++ b/cli/cmd/init_install_config_test.go @@ -13,16 +13,15 @@ import ( . "github.com/onsi/gomega" "github.com/codesphere-cloud/oms/cli/cmd/testutil" - "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" - "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" "github.com/codesphere-cloud/oms/internal/util" ) var _ = Describe("ApplyProfile", func() { DescribeTable("profile application", func(profile string, wantErr bool, checkDatacenterName string) { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err := icg.ApplyProfile(profile) if wantErr { @@ -43,7 +42,7 @@ var _ = Describe("ApplyProfile", func() { Context("dev profile details", func() { It("sets correct dev profile configuration", func() { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err := icg.ApplyProfile("dev") Expect(err).NotTo(HaveOccurred()) @@ -276,7 +275,7 @@ codesphere: Expect(exec.Command("age-keygen", "-o", ageKeyPath).Run()).To(Succeed()) recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() Expect(err).NotTo(HaveOccurred()) - Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultFile.Name(), strings.TrimSpace(string(recipient)))).To(Succeed()) + Expect(sops.EncryptFile(plaintextVaultPath, vaultFile.Name(), strings.TrimSpace(string(recipient)))).To(Succeed()) previousAgeKeyFile, hadPreviousAgeKeyFile := os.LookupEnv("SOPS_AGE_KEY_FILE") Expect(os.Setenv("SOPS_AGE_KEY_FILE", ageKeyPath)).To(Succeed()) DeferCleanup(func() { @@ -296,7 +295,7 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err = c.validateOnly(icg) Expect(err).NotTo(HaveOccurred()) }) @@ -347,7 +346,7 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = c.validateOnly(icg) Expect(err).To(HaveOccurred()) }) @@ -409,7 +408,7 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = c.validateOnly(icg) Expect(err).To(HaveOccurred()) }) diff --git a/cli/cmd/install_config_test_helpers_test.go b/cli/cmd/install_config_test_helpers_test.go new file mode 100644 index 000000000..7d320d4b2 --- /dev/null +++ b/cli/cmd/install_config_test_helpers_test.go @@ -0,0 +1,23 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/oms/internal/installer" + . "github.com/onsi/gomega" +) + +func newPlainInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("plain", "") + Expect(err).NotTo(HaveOccurred()) + + return manager +} + +func newSOPSInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("sops", "") + Expect(err).NotTo(HaveOccurred()) + + return manager +} diff --git a/cli/cmd/install_openbao.go b/cli/cmd/install_openbao.go index 262a22ee7..915415c80 100644 --- a/cli/cmd/install_openbao.go +++ b/cli/cmd/install_openbao.go @@ -23,7 +23,7 @@ import ( "github.com/codesphere-cloud/oms/cli/cmd/util" "github.com/codesphere-cloud/oms/internal/installer" - "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" ) // InstallOpenBaoCmd wraps the cobra command and options for 'oms install openbao'. @@ -60,7 +60,7 @@ func (c *InstallOpenBaoCmd) RunE(_ *cobra.Command, _ []string) error { // Pass --age-key-file explicitly so ResolveAgeKey prefers it without // mutating the process environment. When empty, the normal // auto-discovery chain (env vars, default location, generation) applies. - recipient, keyPath, err := vault.ResolveAgeKey(c.Opts.AgeKeyFile, fallbackDir) + recipient, keyPath, err := sops.ResolveAgeKey(c.Opts.AgeKeyFile, fallbackDir) if err != nil { return fmt.Errorf("resolving age key: %w", err) } diff --git a/cli/cmd/k0s/install_config_test_helpers_test.go b/cli/cmd/k0s/install_config_test_helpers_test.go new file mode 100644 index 000000000..eeb6aa7e7 --- /dev/null +++ b/cli/cmd/k0s/install_config_test_helpers_test.go @@ -0,0 +1,17 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package k0s_test + +import ( + "github.com/codesphere-cloud/oms/internal/installer" + . "github.com/onsi/gomega" +) + +func newPlainInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("plain", "") + Expect(err).NotTo(HaveOccurred()) + return manager +} diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 56a28fdec..f67257fcd 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -40,6 +40,7 @@ type InstallK0sOpts struct { NoDownload bool Vault string VaultPrivKey string + VaultType string } func (c *InstallK0sCmd) RunE(_ *cobra.Command, args []string) error { @@ -88,6 +89,7 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { k0s.cmd.Flags().StringVar(&k0s.Opts.Vault, "vault", "", "Path to prod.vault.yaml to save the kubeconfig into (optional)") k0s.cmd.Flags().StringVar(&k0s.Opts.VaultPrivKey, "vault-priv-key", "", "Path to the age private key to decrypt the vault (optional, for SOPS-encrypted vaults)") + k0s.cmd.Flags().StringVar(&k0s.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") _ = k0s.cmd.MarkFlagRequired("install-config") @@ -145,18 +147,16 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 } func (c *InstallK0sCmd) loadInstallConfig() (*files.RootConfig, error) { - icg := installer.NewInstallConfigManager() - if err := icg.LoadInstallConfigFromFile(c.Opts.InstallConfig); err != nil { + config, err := installer.NewConfig().ParseConfigYaml(c.Opts.InstallConfig) + if err != nil { return nil, fmt.Errorf("failed to load install-config: %w", err) } - config := icg.GetInstallConfig() - if !config.Kubernetes.ManagedByCodesphere { return nil, fmt.Errorf("install-config specifies external Kubernetes, k0s installation is only supported for Codesphere-managed Kubernetes") } - return config, nil + return &config, nil } func (c *InstallK0sCmd) determineK0sVersion(k0s installer.K0sManager) (string, error) { @@ -261,12 +261,12 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 }, }) - vaultYAML, err := vault.Marshal() + store, err := c.vaultStore() if err != nil { - return fmt.Errorf("failed to marshal vault: %w", err) + return err } - if err := c.writeEncryptedVault(vaultYAML); err != nil { + if err := store.Save(vault); err != nil { return err } @@ -274,39 +274,24 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 return nil } -// writeEncryptedVault writes vaultYAML to the vault path, encrypting it with SOPS. -// Uses a temporary file so the original vault is left untouched on failure. -func (c *InstallK0sCmd) writeEncryptedVault(vaultYAML []byte) error { - tmpPath := c.Opts.Vault + ".tmp" - - if err := c.FileWriter.WriteFile(tmpPath, vaultYAML, 0600); err != nil { - return fmt.Errorf("failed to write temporary vault file: %w", err) - } - - recipient, _, err := vault.ResolveAgeKey(c.Opts.VaultPrivKey, "") +func (c *InstallK0sCmd) loadOrCreateVault() (*files.InstallVault, error) { + store, err := c.vaultStore() if err != nil { - _ = c.FileWriter.Remove(tmpPath) - return fmt.Errorf("failed to resolve age key for vault rencryption: %w", err) + return nil, err } - if err := vault.EncryptFileWithSOPS(tmpPath, c.Opts.Vault, recipient); err != nil { - _ = c.FileWriter.Remove(tmpPath) - return fmt.Errorf("failed to encrypt vault file: %w", err) + data, err := store.LoadOrCreate() + if err != nil { + return nil, fmt.Errorf("failed to load vault: %w", err) } - _ = c.FileWriter.Remove(tmpPath) - return nil + return data, nil } -func (c *InstallK0sCmd) loadOrCreateVault() (*files.InstallVault, error) { - if !c.FileWriter.Exists(c.Opts.Vault) { - return &files.InstallVault{}, nil - } - - vault, err := vault.LoadVaultData(c.Opts.Vault, c.Opts.VaultPrivKey) +func (c *InstallK0sCmd) vaultStore() (vault.Vault, error) { + vault, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.Vault, AgeKey: c.Opts.VaultPrivKey}) if err != nil { return nil, fmt.Errorf("failed to load vault: %w", err) } - return vault, nil } diff --git a/cli/cmd/k0s/install_k0s_integration_test.go b/cli/cmd/k0s/install_k0s_integration_test.go index c949116cf..7e5ec40b3 100644 --- a/cli/cmd/k0s/install_k0s_integration_test.go +++ b/cli/cmd/k0s/install_k0s_integration_test.go @@ -80,7 +80,7 @@ var _ = Describe("K0s Install-Config Integration", func() { err = os.WriteFile(configPath, configData, 0644) Expect(err).NotTo(HaveOccurred()) - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = icg.LoadInstallConfigFromFile(configPath) Expect(err).NotTo(HaveOccurred()) @@ -254,7 +254,7 @@ var _ = Describe("K0s Install-Config Integration", func() { Describe("Error Handling", func() { It("should fail when loading non-existent file", func() { nonExistentPath := filepath.Join(tempDir, "does-not-exist.yaml") - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err := icg.LoadInstallConfigFromFile(nonExistentPath) Expect(err).To(HaveOccurred()) }) @@ -270,7 +270,7 @@ var _ = Describe("K0s Install-Config Integration", func() { err := os.WriteFile(configPath, invalidYAML, 0644) Expect(err).NotTo(HaveOccurred()) - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = icg.LoadInstallConfigFromFile(configPath) Expect(err).To(HaveOccurred()) }) @@ -279,7 +279,7 @@ var _ = Describe("K0s Install-Config Integration", func() { err := os.WriteFile(configPath, []byte{}, 0644) Expect(err).NotTo(HaveOccurred()) - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = icg.LoadInstallConfigFromFile(configPath) // Empty file loads successfully but returns empty config Expect(err).NotTo(HaveOccurred()) @@ -386,7 +386,7 @@ var _ = Describe("K0s Install-Config Integration", func() { Expect(err).NotTo(HaveOccurred()) // Reload install-config - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = icg.LoadInstallConfigFromFile(configPath) Expect(err).NotTo(HaveOccurred()) reloadedInstallConfig := icg.GetInstallConfig() diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 0c46fe437..7a4bf2c03 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -274,11 +274,9 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - encrypted, err := vault.IsSOPSEncryptedFile(c.Opts.Vault) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: c.Opts.Vault, AgeKey: ageKeyPath}) Expect(err).NotTo(HaveOccurred()) - Expect(encrypted).To(BeTrue(), "new vault should be SOPS-encrypted") - - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) + loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) @@ -328,7 +326,9 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: c.Opts.Vault, AgeKey: ageKeyPath}) + Expect(err).NotTo(HaveOccurred()) + loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) Expect(loaded.GetSecret("domainAuthPrivateKey")).NotTo(BeNil(), "pre-existing secret should be preserved") secret := loaded.GetSecret(files.SecretKubeConfig) @@ -378,7 +378,9 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: c.Opts.Vault, AgeKey: ageKeyPath}) + Expect(err).NotTo(HaveOccurred()) + loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) @@ -405,7 +407,9 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: c.Opts.Vault, AgeKey: ageKeyPath}) + Expect(err).NotTo(HaveOccurred()) + loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) @@ -463,10 +467,6 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err).NotTo(HaveOccurred(), string(encryptOut)) Expect(os.Remove(plainPath)).To(Succeed()) - encrypted, err := vault.IsSOPSEncryptedFile(vaultPath) - Expect(err).NotTo(HaveOccurred()) - Expect(encrypted).To(BeTrue()) - c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Package = "test-package.tar.gz" c.Opts.Version = "v1.30.0+k0s.0" @@ -479,11 +479,6 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - // Verify the vault was re-encrypted after saving kubeconfig. - encrypted, err = vault.IsSOPSEncryptedFile(vaultPath) - Expect(err).NotTo(HaveOccurred()) - Expect(encrypted).To(BeTrue(), "vault should be re-encrypted after saving kubeconfig") - // Verify the temporary file was cleaned up. tmpPath := vaultPath + ".tmp" Expect(tmpPath).NotTo(BeAnExistingFile()) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 591b1c8b0..0f9831663 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -61,6 +61,7 @@ func GetRootCmd() *cobra.Command { // Package commands AddListCmd(rootCmd, opts) AddDownloadCmd(rootCmd, opts) + AddCopyCmd(rootCmd, opts) AddInstallCmd(rootCmd, opts) AddInitCmd(rootCmd, opts) AddTemplateCmd(rootCmd, opts) @@ -71,7 +72,11 @@ func GetRootCmd() *cobra.Command { apikey.AddRegisterCmd(rootCmd, opts) AddRevokeCmd(rootCmd, opts) - // Smoke test commands + // Status and test commands + AddStatusCmd(rootCmd, opts) + AddTestCmd(rootCmd, opts) + + // Deprecated, superseded by the smoketest step of 'oms test codesphere'. AddSmoketestCmd(rootCmd, opts) // Resource creation commands diff --git a/cli/cmd/smoketest.go b/cli/cmd/smoketest.go index 2d7ac6d8e..cd5ed95af 100644 --- a/cli/cmd/smoketest.go +++ b/cli/cmd/smoketest.go @@ -18,9 +18,10 @@ type SmoketestCmd struct { func AddSmoketestCmd(rootCmd *cobra.Command, opts *util.GlobalOptions) { smoketest := SmoketestCmd{ cmd: &cobra.Command{ - Use: "smoketest", - Short: "Run smoke tests for Codesphere components", - Long: io.Long(`Run automated smoke tests for Codesphere installations to verify functionality.`), + Use: "smoketest", + Short: "Run smoke tests for Codesphere components (deprecated)", + Long: io.Long(`Run automated smoke tests for Codesphere installations to verify functionality.`), + Deprecated: codesphere.SmoketestDeprecation, }, } util.AddCmd(rootCmd, smoketest.cmd) diff --git a/cli/cmd/status.go b/cli/cmd/status.go new file mode 100644 index 000000000..878760db0 --- /dev/null +++ b/cli/cmd/status.go @@ -0,0 +1,30 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/cli/cmd/codesphere" + "github.com/codesphere-cloud/oms/cli/cmd/util" + "github.com/spf13/cobra" +) + +// StatusCmd represents the status command +type StatusCmd struct { + cmd *cobra.Command +} + +// AddStatusCmd adds the status command and its subcommands to the root command. +func AddStatusCmd(rootCmd *cobra.Command, opts *util.GlobalOptions) { + status := StatusCmd{ + cmd: &cobra.Command{ + Use: "status", + Short: "Check the status of Codesphere components", + Long: io.Long(`Check whether Codesphere installations or components are up and ready.`), + }, + } + util.AddCmd(rootCmd, status.cmd) + + codesphere.AddStatusCmd(status.cmd, opts) +} diff --git a/cli/cmd/template_config.go b/cli/cmd/template_config.go index 48d1010c3..37fead88d 100644 --- a/cli/cmd/template_config.go +++ b/cli/cmd/template_config.go @@ -21,9 +21,10 @@ type TemplateConfigCmd struct { type TemplateConfigOpts struct { *util.GlobalOptions - Config string - Vault string - AgeKey string + Config string + Vault string + AgeKey string + VaultType string } func (c *TemplateConfigCmd) RunE(cmd *cobra.Command, _ []string) error { @@ -55,14 +56,14 @@ This command prints the rendered configuration to stdout so templating can be te Template syntax in config.yaml: # Inject a secret value (defaults to the "content"/"password" field) - someKey: "{{ secret "mySecret" }}" + someKey: '{{ secret "mySecret" }}' # Select a specific field - username: "{{ secret "mySecret" "fields.username" }}" - password: "{{ secret "mySecret" "fields.password" }}" + username: '{{ secret "mySecret" "fields.username" }}' + password: '{{ secret "mySecret" "fields.password" }}' # Inject a file secret's content - caCert: "{{ secret "caCert" "file.content" }}" + caCert: '{{ secret "caCert" "file.content" }}' Secret names and selectors must match entries in the prod.vault.yaml file.`), Example: util.FormatExamples("template config", []io.Example{ @@ -77,12 +78,12 @@ Secret names and selectors must match entries in the prod.vault.yaml file.`), } configCmd.cmd.Flags().StringVarP(&configCmd.Opts.Config, "config", "c", "", "Path to the config.yaml template to render (required)") - configCmd.cmd.Flags().StringVarP(&configCmd.Opts.Vault, "vault", "v", "", "Path to the SOPS-encrypted prod.vault.yaml file (required)") - configCmd.cmd.Flags().StringVarP(&configCmd.Opts.AgeKey, "age-key", "k", "", "Path to the age key file used to decrypt the vault (required)") + configCmd.cmd.Flags().StringVarP(&configCmd.Opts.Vault, "vault", "v", "", "Path to the prod.vault.yaml file (required)") + configCmd.cmd.Flags().StringVarP(&configCmd.Opts.AgeKey, "age-key", "k", "", "Path to the age key file (required for sops unless an age key environment variable is set)") + configCmd.cmd.Flags().StringVar(&configCmd.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") util.MarkFlagRequired(configCmd.cmd, "config") util.MarkFlagRequired(configCmd.cmd, "vault") - util.MarkFlagRequired(configCmd.cmd, "age-key") util.AddCmd(parentCmd, configCmd.cmd) @@ -95,7 +96,12 @@ func (c *TemplateConfigCmd) Render() ([]byte, error) { return nil, fmt.Errorf("failed to read config file %s: %w", c.Opts.Config, err) } - store := vault.NewLazyVaultTemplatingSecretStore(c.Opts.Vault, c.Opts.AgeKey) + backend, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.Vault, AgeKey: c.Opts.AgeKey}) + if err != nil { + return nil, fmt.Errorf("failed to load vault: %w", err) + } + + store := vault.NewLazyVaultTemplatingSecretStoreWithVault(backend) rendered, err := configtemplating.RenderInstallConfigTemplate(data, store) if err != nil { return nil, fmt.Errorf("failed to render config template: %w", err) diff --git a/cli/cmd/template_config_test.go b/cli/cmd/template_config_test.go index 8e611f0ac..160ff5350 100644 --- a/cli/cmd/template_config_test.go +++ b/cli/cmd/template_config_test.go @@ -13,7 +13,7 @@ import ( "github.com/codesphere-cloud/oms/cli/cmd" "github.com/codesphere-cloud/oms/cli/cmd/testutil" "github.com/codesphere-cloud/oms/internal/installer/files" - "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -64,7 +64,7 @@ postgres: Expect(os.WriteFile(plaintextVaultPath, vaultYaml, 0600)).To(Succeed()) recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() Expect(err).NotTo(HaveOccurred()) - Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) + Expect(sops.EncryptFile(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) rootCmd := cmd.GetRootCmd() var output bytes.Buffer diff --git a/cli/cmd/test.go b/cli/cmd/test.go new file mode 100644 index 000000000..0e60c6510 --- /dev/null +++ b/cli/cmd/test.go @@ -0,0 +1,63 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/cli/cmd/codesphere" + "github.com/codesphere-cloud/oms/cli/cmd/util" + "github.com/spf13/cobra" +) + +// TestCmd represents the test command +type TestCmd struct { + cmd *cobra.Command +} + +// TestListCmd represents the test list command +type TestListCmd struct { + cmd *cobra.Command +} + +// AddTestCmd adds the test command and its subcommands to the root command. +func AddTestCmd(rootCmd *cobra.Command, opts *util.GlobalOptions) { + test := TestCmd{ + cmd: &cobra.Command{ + Use: "test", + Short: "Run playlists of tests against Codesphere components", + Long: io.Long(`Run playlists of tests against Codesphere components. + + A playlist bundles individual tests, such as a status report or a smoke test, + into a single run with a summarized result.`), + }, + } + util.AddCmd(rootCmd, test.cmd) + + codesphere.AddTestCmd(test.cmd, opts) + AddTestListCmd(test.cmd) +} + +// AddTestListCmd adds the test list command to the given parent command. +func AddTestListCmd(parent *cobra.Command) { + list := TestListCmd{ + cmd: &cobra.Command{ + Use: "list", + Short: "List the available tests and playlists", + Long: io.Long(`List the tests that can be run against a Codesphere installation and the playlists that group them.`), + Example: util.FormatExamples("test list", []io.Example{ + { + Cmd: "", + Desc: "List the available tests and playlists", + }, + }), + }, + } + + list.cmd.RunE = func(cmd *cobra.Command, _ []string) error { + codesphere.Registry(&codesphere.TestCodesphereOpts{}).Describe(cmd.OutOrStdout()) + return nil + } + + util.AddCmd(parent, list.cmd) +} diff --git a/cli/cmd/update_install_config.go b/cli/cmd/update_install_config.go index 26551adf4..3260e1deb 100644 --- a/cli/cmd/update_install_config.go +++ b/cli/cmd/update_install_config.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "log" + "sort" "strings" csio "github.com/codesphere-cloud/cs-go/pkg/io" @@ -13,6 +14,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/secrets" + "github.com/codesphere-cloud/oms/internal/prompt" intutil "github.com/codesphere-cloud/oms/internal/util" "github.com/spf13/cobra" ) @@ -21,6 +23,10 @@ type UpdateInstallConfigCmd struct { cmd *cobra.Command Opts *UpdateInstallConfigOpts FileWriter intutil.FileIO + + // Prompter asks the operator whether to go ahead with a change to the vault. + // --yes short-circuits it. + Prompter prompt.Prompter } type UpdateInstallConfigOpts struct { @@ -28,8 +34,11 @@ type UpdateInstallConfigOpts struct { ConfigFile string VaultFile string + VaultType string + AgeKey string WithComments bool + Yes bool // Fields that can be updated PostgresPrimaryIP string @@ -66,7 +75,10 @@ type UpdateInstallConfigOpts struct { } func (c *UpdateInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { - icg := installer.NewInstallConfigManager() + icg, err := installer.NewInstallConfigManager(c.Opts.VaultType, c.Opts.AgeKey) + if err != nil { + return fmt.Errorf("failed to initialize config manager: %w", err) + } return c.UpdateInstallConfig(icg) } @@ -95,12 +107,18 @@ func AddUpdateInstallConfigCmd(update *cobra.Command, opts *util.GlobalOptions) }, Opts: &UpdateInstallConfigOpts{GlobalOptions: opts}, FileWriter: intutil.NewFilesystemWriter(), + // One prompter for the whole command: it buffers stdin, so a fresh one per + // question could drop what the operator already typed. + Prompter: prompt.NewPrompter(true), } c.cmd.Flags().StringVarP(&c.Opts.ConfigFile, "config", "c", "config.yaml", "Path to existing config.yaml file") c.cmd.Flags().StringVar(&c.Opts.VaultFile, "vault", "prod.vault.yaml", "Path to existing prod.vault.yaml file") + c.cmd.Flags().StringVar(&c.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") + c.cmd.Flags().StringVar(&c.Opts.AgeKey, "age-key", "", "Path to the age private key (required for sops unless SOPS_AGE_KEY or SOPS_AGE_KEY_FILE is set)") c.cmd.Flags().BoolVar(&c.Opts.WithComments, "with-comments", false, "Add helpful comments to the generated YAML files") + c.cmd.Flags().BoolVarP(&c.Opts.Yes, "yes", "y", false, "Auto-approve every change to the vault (regenerated certificates and missing secrets)") // PostgreSQL update flags c.cmd.Flags().StringVar(&c.Opts.PostgresPrimaryIP, "postgres-primary-ip", "", "Primary PostgreSQL server IP") @@ -174,6 +192,13 @@ func (c *UpdateInstallConfigCmd) UpdateInstallConfig(icg installer.InstallConfig } if tracker.HasChanges() { + if !c.approve("Regenerate them?", "The changes above require these secrets to be regenerated:", tracker.Regenerates()) { + // The regenerated certificates cover values that were just written to the config, + // so keeping the old ones would leave the two inconsistent. Nothing has been + // written yet, so stopping here leaves the installation as it was. + return fmt.Errorf("aborted: the requested changes cannot be applied without regenerating the secrets above (pass --yes to approve up front)") + } + log.Println("\nRegenerating affected secrets and certificates...") if err := c.regenerateSecrets(config, vault, tracker); err != nil { return fmt.Errorf("failed to regenerate secrets: %w", err) @@ -182,6 +207,11 @@ func (c *UpdateInstallConfigCmd) UpdateInstallConfig(icg installer.InstallConfig log.Println("\nNo changes detected that require secret regeneration.") } + added, err := c.confirmAndAddMissingSecrets(config, vault) + if err != nil { + return err + } + if err := icg.WriteInstallConfig(c.Opts.ConfigFile, c.Opts.WithComments); err != nil { return fmt.Errorf("failed to write config file: %w", err) } @@ -190,7 +220,7 @@ func (c *UpdateInstallConfigCmd) UpdateInstallConfig(icg installer.InstallConfig return fmt.Errorf("failed to write vault file: %w", err) } - c.printSuccessMessage(tracker) + c.printSuccessMessage(tracker, added) return nil } @@ -398,6 +428,88 @@ func (c *UpdateInstallConfigCmd) applyCodesphereUpdates(config *files.RootConfig } } +// approve prints what is about to change and asks the operator to confirm it. --yes approves +// without asking; otherwise only an explicit yes counts, so a run without a terminal (an +// empty answer) declines. +func (c *UpdateInstallConfigCmd) approve(question, intro string, items []string) bool { + if c.Opts.Yes { + return true + } + + log.Printf("\n%s\n", intro) + + for _, item := range items { + log.Printf(" - %s\n", item) + } + + return c.Prompter.Bool(question, false) +} + +// confirmAndAddMissingSecrets asks about the secrets the vault is missing and adds them if +// the operator agrees. Returns the names of the ones that were added, none if the operator +// declined. +func (c *UpdateInstallConfigCmd) confirmAndAddMissingSecrets(config *files.RootConfig, vault *files.InstallVault) ([]string, error) { + missing, err := missingSecrets(config, vault) + if err != nil { + return nil, fmt.Errorf("failed to determine missing secrets: %w", err) + } + + if len(missing) == 0 { + return nil, nil + } + + if !c.approve("Generate them?", "The vault does not have these secrets yet:", missing) { + log.Printf("\nSkipped %d missing secret(s): %s\n", len(missing), strings.Join(missing, ", ")) + + return nil, nil + } + + added, err := addMissingSecrets(config, vault) + if err != nil { + return nil, fmt.Errorf("failed to add missing secrets: %w", err) + } + + log.Printf("\nAdded %d secret(s) missing from the vault: %s\n", len(added), strings.Join(added, ", ")) + + return added, nil +} + +// missingSecrets reports what addMissingSecrets would generate, without changing anything: +// it runs against copies, so only the names survive. +func missingSecrets(config *files.RootConfig, vault *files.InstallVault) ([]string, error) { + configCopy, err := config.Clone() + if err != nil { + return nil, fmt.Errorf("copy config: %w", err) + } + + return addMissingSecrets(configCopy, vault.Clone()) +} + +// addMissingSecrets generates the secrets the vault does not have yet and returns their +// names. EnsureSecrets keeps what is already there, so this only ever adds. +func addMissingSecrets(config *files.RootConfig, vault *files.InstallVault) ([]string, error) { + existing := make(map[string]bool, len(vault.Secrets)) + for _, secret := range vault.Secrets { + existing[secret.Name] = true + } + + if err := secrets.EnsureSecrets(vault, config); err != nil { + return nil, fmt.Errorf("ensure secrets: %w", err) + } + + added := []string{} + + for _, secret := range vault.Secrets { + if !existing[secret.Name] { + added = append(added, secret.Name) + } + } + + sort.Strings(added) + + return added, nil +} + func (c *UpdateInstallConfigCmd) regenerateSecrets(config *files.RootConfig, vault *files.InstallVault, tracker *SecretDependencyTracker) error { if tracker.NeedsPostgresPrimaryCertRegen() { log.Println(" - Regenerating PostgreSQL primary server certificate...") @@ -440,21 +552,24 @@ func (c *UpdateInstallConfigCmd) regenerateSecrets(config *files.RootConfig, vau return nil } -func (c *UpdateInstallConfigCmd) printSuccessMessage(tracker *SecretDependencyTracker) { +func (c *UpdateInstallConfigCmd) printSuccessMessage(tracker *SecretDependencyTracker, added []string) { log.Println("\n" + strings.Repeat("=", 70)) log.Println("Configuration successfully updated!") log.Println(strings.Repeat("=", 70)) if tracker.HasChanges() { log.Println("\nRegenerated secrets:") - if tracker.NeedsPostgresPrimaryCertRegen() { - log.Println(" ✓ PostgreSQL primary server certificate") - } - if tracker.NeedsPostgresReplicaCertRegen() { - log.Println(" ✓ PostgreSQL replica server certificate") + + for _, change := range tracker.Regenerates() { + log.Printf(" ✓ %s\n", change) } - if tracker.ACMEConfigChanged() { - log.Println(" ✓ ACME configuration updated") + } + + if len(added) > 0 { + log.Println("\nGenerated missing secrets:") + + for _, name := range added { + log.Printf(" ✓ %s\n", name) } } @@ -496,6 +611,26 @@ func (t *SecretDependencyTracker) ACMEConfigChanged() bool { return t.acmeConfigChanged } +// Regenerates describes, in operator-facing terms, what the tracked changes cause to be +// regenerated. Drives both the confirmation prompt and the summary, so the two cannot drift. +func (t *SecretDependencyTracker) Regenerates() []string { + changes := []string{} + + if t.postgresPrimaryCertNeedsRegen { + changes = append(changes, "PostgreSQL primary server certificate") + } + + if t.postgresReplicaCertNeedsRegen { + changes = append(changes, "PostgreSQL replica server certificate") + } + + if t.acmeConfigChanged { + changes = append(changes, "ACME configuration") + } + + return changes +} + func (t *SecretDependencyTracker) HasChanges() bool { return t.postgresPrimaryCertNeedsRegen || t.postgresReplicaCertNeedsRegen || t.acmeConfigChanged } diff --git a/cli/cmd/update_install_config_test.go b/cli/cmd/update_install_config_test.go index 8d078373f..8c62cbe62 100644 --- a/cli/cmd/update_install_config_test.go +++ b/cli/cmd/update_install_config_test.go @@ -12,13 +12,15 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" "github.com/codesphere-cloud/oms/cli/cmd/testutil" "github.com/codesphere-cloud/oms/cli/cmd/util" - "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/secrets" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" + "github.com/codesphere-cloud/oms/internal/prompt" ) func quoteYAMLString(s string) string { @@ -37,8 +39,11 @@ var _ = Describe("UpdateInstallConfig", func() { initialVault string cmd *UpdateInstallConfigCmd opts *UpdateInstallConfigOpts - testCAKeyPem string - testCACertPem string + confirmations []string + // Answer the stubbed prompts with. Reset to true for every spec. + approveConfirmations bool + testCAKeyPem string + testCACertPem string ) BeforeEach(func() { @@ -46,6 +51,8 @@ var _ = Describe("UpdateInstallConfig", func() { Skip("sops and age-keygen not available") } + approveConfirmations = true + var err error configFile, err = os.CreateTemp("", "config-*.yaml") Expect(err).NotTo(HaveOccurred()) @@ -196,7 +203,7 @@ codesphere: Expect(exec.Command("age-keygen", "-o", ageKeyPath).Run()).To(Succeed()) recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() Expect(err).NotTo(HaveOccurred()) - Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultFile.Name(), strings.TrimSpace(string(recipient)))).To(Succeed()) + Expect(sops.EncryptFile(plaintextVaultPath, vaultFile.Name(), strings.TrimSpace(string(recipient)))).To(Succeed()) previousAgeKeyFile, hadPreviousAgeKeyFile := os.LookupEnv("SOPS_AGE_KEY_FILE") Expect(os.Setenv("SOPS_AGE_KEY_FILE", ageKeyPath)).To(Succeed()) DeferCleanup(func() { @@ -213,8 +220,20 @@ codesphere: VaultFile: vaultFile.Name(), } + confirmations = nil + prompter := prompt.NewMockPrompter(GinkgoT()) + // Records what was asked and answers it the way the spec asked for. Optional, + // because a spec that passes --yes never gets to ask. + prompter.EXPECT().Bool(mock.Anything, false). + RunAndReturn(func(question string, _ bool) bool { + confirmations = append(confirmations, question) + + return approveConfirmations + }).Maybe() + cmd = &UpdateInstallConfigCmd{ - Opts: opts, + Opts: opts, + Prompter: prompter, } }) @@ -228,7 +247,7 @@ codesphere: opts.PostgresPrimaryIP = "10.10.0.4" opts.PostgresServer = "new-postgres-primary" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -238,10 +257,9 @@ codesphere: Expect(icg.GetVault().GetSecret(files.SecretPostgresPrimaryServerKeyPem)).NotTo(BeNil()) Expect(config.Postgres.Primary.SSLConfig.ServerCertPem).NotTo(BeEmpty()) - encrypted, err := vault.IsSOPSEncryptedFile(vaultFile.Name()) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultFile.Name()}) Expect(err).NotTo(HaveOccurred()) - Expect(encrypted).To(BeTrue()) - updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") + updatedVault, err := backend.Load() Expect(err).NotTo(HaveOccurred()) Expect(updatedVault.GetSecret(files.SecretPostgresPrimaryServerKeyPem)).NotTo(BeNil()) }) @@ -250,7 +268,7 @@ codesphere: opts.PostgresReplicaIP = "10.10.0.7" opts.PostgresReplicaName = "new_replica" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -270,7 +288,7 @@ codesphere: opts.CodespherePublicIP = "203.0.113.100" opts.KubernetesPodCIDR = "10.244.0.0/16" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -292,7 +310,7 @@ codesphere: opts.KubernetesPodCIDR = "100.96.0.0/11" opts.KubernetesServiceCIDR = "100.64.0.0/13" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -308,7 +326,7 @@ codesphere: opts.ClusterGatewayServiceType = "NodePort" opts.ClusterGatewayIPAddresses = []string{"192.168.1.200", "192.168.1.201"} - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -324,7 +342,7 @@ codesphere: opts.CodesphereDNSServers = []string{"1.1.1.1", "1.0.0.1"} opts.CodesphereWorkspaceHostingBaseDomain = "workspaces.updated.example.com" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -339,7 +357,7 @@ codesphere: It("should update Ceph nodes subnet", func() { opts.CephNodesSubnet = "10.53.102.0/24" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -355,11 +373,74 @@ codesphere: }) }) + Context("confirming changes to the vault", func() { + // The fixture vault holds only some of the secrets EnsureSecrets knows about, + // so every run of the command finds something to generate. + It("asks before generating a secret the vault does not have", func() { + icg := newSOPSInstallConfigManager() + Expect(cmd.UpdateInstallConfig(icg)).To(Succeed()) + + Expect(confirmations).To(HaveLen(1)) + Expect(icg.GetVault().GetSecret(files.SecretMounterHmacSecret)).ToNot(BeNil()) + }) + + It("leaves the vault alone when the operator declines", func() { + approveConfirmations = false + + icg := newSOPSInstallConfigManager() + Expect(cmd.UpdateInstallConfig(icg)).To(Succeed()) + + Expect(icg.GetVault().GetSecret(files.SecretMounterHmacSecret)).To(BeNil()) + + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultFile.Name()}) + Expect(err).NotTo(HaveOccurred()) + writtenVault, err := backend.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(writtenVault.GetSecret(files.SecretMounterHmacSecret)).To(BeNil()) + }) + + It("asks nothing with --yes", func() { + opts.Yes = true + + icg := newSOPSInstallConfigManager() + Expect(cmd.UpdateInstallConfig(icg)).To(Succeed()) + + Expect(confirmations).To(BeEmpty()) + Expect(icg.GetVault().GetSecret(files.SecretMounterHmacSecret)).ToNot(BeNil()) + }) + + It("asks before regenerating certificates an update invalidates", func() { + opts.PostgresPrimaryIP = "10.10.0.4" + + icg := newSOPSInstallConfigManager() + Expect(cmd.UpdateInstallConfig(icg)).To(Succeed()) + + Expect(confirmations).To(HaveLen(2)) + }) + + // A declined regeneration would leave the config pointing at an IP the + // certificate does not cover, so the whole update is dropped instead. + It("writes nothing when the operator declines a regeneration", func() { + opts.PostgresPrimaryIP = "10.10.0.4" + approveConfirmations = false + + icg := newSOPSInstallConfigManager() + err := cmd.UpdateInstallConfig(icg) + + Expect(err).To(MatchError(ContainSubstring("aborted"))) + + written := newSOPSInstallConfigManager() + Expect(written.LoadInstallConfigFromFile(configFile.Name())).To(Succeed()) + Expect(written.GetInstallConfig().Postgres.Primary.IP).To(Equal("10.0.0.5")) + Expect(confirmations).To(HaveLen(1)) + }) + }) + Context("when loading invalid config file", func() { It("should return an error", func() { opts.ConfigFile = "/nonexistent/config.yaml" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to load config file")) @@ -370,7 +451,7 @@ codesphere: It("should return an error", func() { opts.VaultFile = "/nonexistent/vault.yaml" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to load vault file")) @@ -396,11 +477,13 @@ codesphere: } opts.CodesphereDomain = "updated.example.com" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err = cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) - updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultFile.Name()}) + Expect(err).NotTo(HaveOccurred()) + updatedVault, err := backend.Load() Expect(err).NotTo(HaveOccurred()) // Verify all initial secrets are still present with the same values @@ -427,11 +510,13 @@ codesphere: } opts.PostgresPrimaryIP = "10.20.0.10" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err = cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) - updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultFile.Name()}) + Expect(err).NotTo(HaveOccurred()) + updatedVault, err := backend.Load() Expect(err).NotTo(HaveOccurred()) // Verify all initial secrets are still present with the same values @@ -482,3 +567,79 @@ var _ = Describe("SecretDependencyTracker", func() { Expect(tracker.NeedsPostgresReplicaCertRegen()).To(BeTrue()) }) }) + +var _ = Describe("missingSecrets", func() { + It("reports what is missing without changing config or vault", func() { + config := &files.RootConfig{} + vault := &files.InstallVault{} + + missing, err := missingSecrets(config, vault) + + Expect(err).ToNot(HaveOccurred()) + Expect(missing).To(ContainElement(files.SecretMounterHmacSecret)) + Expect(vault.Secrets).To(BeEmpty()) + Expect(config.Cluster.Certificates.CA.CertPem).To(BeEmpty()) + }) + + It("reports nothing once the secrets are there", func() { + config := &files.RootConfig{} + vault := &files.InstallVault{} + _, err := addMissingSecrets(config, vault) + Expect(err).ToNot(HaveOccurred()) + + Expect(missingSecrets(config, vault)).To(BeEmpty()) + }) +}) + +var _ = Describe("addMissingSecrets", func() { + var config *files.RootConfig + + BeforeEach(func() { + config = &files.RootConfig{} + }) + + It("adds a secret the vault does not have", func() { + vault := &files.InstallVault{} + + added, err := addMissingSecrets(config, vault) + + Expect(err).ToNot(HaveOccurred()) + Expect(added).To(ContainElement(files.SecretMounterHmacSecret)) + Expect(vault.GetSecret(files.SecretMounterHmacSecret)).ToNot(BeNil()) + }) + + It("reports nothing on a second run and keeps the generated value", func() { + vault := &files.InstallVault{} + _, err := addMissingSecrets(config, vault) + Expect(err).ToNot(HaveOccurred()) + + secret := vault.GetSecret(files.SecretMounterHmacSecret).Fields.Password + + added, err := addMissingSecrets(config, vault) + + Expect(err).ToNot(HaveOccurred()) + Expect(added).To(BeEmpty()) + Expect(vault.GetSecret(files.SecretMounterHmacSecret).Fields.Password).To(Equal(secret)) + }) + + It("never modifies a secret the vault already holds", func() { + vault := &files.InstallVault{} + vault.SetSecret(files.SecretEntry{ + Name: files.SecretMounterHmacSecret, + Fields: &files.SecretFields{Password: "operator-supplied-secret"}, + }) + // EnsureDefaultSecrets overwrites this one unconditionally when it runs directly. + vault.SetSecret(files.SecretEntry{ + Name: files.SecretDigitalOceanApiToken, + Fields: &files.SecretFields{Password: "a-real-token"}, + }) + + added, err := addMissingSecrets(config, vault) + + Expect(err).ToNot(HaveOccurred()) + Expect(added).ToNot(ContainElement(files.SecretMounterHmacSecret)) + Expect(added).ToNot(ContainElement(files.SecretDigitalOceanApiToken)) + Expect(vault.GetSecret(files.SecretMounterHmacSecret).Fields.Password).To(Equal("operator-supplied-secret")) + Expect(vault.GetSecret(files.SecretDigitalOceanApiToken).Fields.Password).To(Equal("a-real-token")) + }) +}) diff --git a/docs/README.md b/docs/README.md index dbbe2f036..7b6eda8fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ like downloading new versions. * [oms add-cluster-admin](oms_add-cluster-admin.md) - Set the cluster admin email in a Kubernetes secret * [oms beta](oms_beta.md) - Commands for early testing * [oms build](oms_build.md) - Build and push images to a registry +* [oms copy](oms_copy.md) - Copy resources between locations * [oms create](oms_create.md) - Create resources for Codesphere * [oms download](oms_download.md) - Download resources available through OMS * [oms init](oms_init.md) - Initialize configuration files @@ -29,7 +30,9 @@ like downloading new versions. * [oms register](oms_register.md) - Register a new API key * [oms revoke](oms_revoke.md) - Revoke resources available through OMS * [oms smoketest](oms_smoketest.md) - Run smoke tests for Codesphere components +* [oms status](oms_status.md) - Check the status of Codesphere components * [oms template](oms_template.md) - Render OMS configuration templates +* [oms test](oms_test.md) - Run playlists of tests against Codesphere components * [oms update](oms_update.md) - Update OMS related resources * [oms version](oms_version.md) - Print version diff --git a/docs/oms.md b/docs/oms.md index dbbe2f036..7b6eda8fb 100644 --- a/docs/oms.md +++ b/docs/oms.md @@ -20,6 +20,7 @@ like downloading new versions. * [oms add-cluster-admin](oms_add-cluster-admin.md) - Set the cluster admin email in a Kubernetes secret * [oms beta](oms_beta.md) - Commands for early testing * [oms build](oms_build.md) - Build and push images to a registry +* [oms copy](oms_copy.md) - Copy resources between locations * [oms create](oms_create.md) - Create resources for Codesphere * [oms download](oms_download.md) - Download resources available through OMS * [oms init](oms_init.md) - Initialize configuration files @@ -29,7 +30,9 @@ like downloading new versions. * [oms register](oms_register.md) - Register a new API key * [oms revoke](oms_revoke.md) - Revoke resources available through OMS * [oms smoketest](oms_smoketest.md) - Run smoke tests for Codesphere components +* [oms status](oms_status.md) - Check the status of Codesphere components * [oms template](oms_template.md) - Render OMS configuration templates +* [oms test](oms_test.md) - Run playlists of tests against Codesphere components * [oms update](oms_update.md) - Update OMS related resources * [oms version](oms_version.md) - Print version diff --git a/docs/oms_beta.md b/docs/oms_beta.md index 3e19c4f21..4c0386d56 100644 --- a/docs/oms_beta.md +++ b/docs/oms_beta.md @@ -20,5 +20,5 @@ Be aware that that usage and behavior may change as the features are developed. * [oms beta bootstrap-local](oms_beta_bootstrap-local.md) - Bootstrap a local Codesphere environment * [oms beta extend](oms_beta_extend.md) - Extend Codesphere ressources such as base images. * [oms beta install](oms_beta_install.md) - Install beta components -* [oms beta vault-secret](oms_beta_vault-secret.md) - Create a Kubernetes secret from a SOPS-encrypted vault file +* [oms beta vault-secret](oms_beta_vault-secret.md) - Create a Kubernetes secret from a vault file diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index fd6e88e75..096b3081d 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -66,7 +66,7 @@ oms beta bootstrap-gcp [flags] --openbao-uri string URI for OpenBao (optional) --openbao-user string OpenBao username (optional) (default "admin") --preemptible Use preemptible VMs for Codesphere infrastructure. Mutually exclusive with --spot-vms (default: false) - --preview-flags stringArray Preview flags to enable in Codesphere installation (optional) (default [openfga-authz,cluster-admin,secret-management,sub-path-mount,workspace-ssh]) + --preview-flags stringArray Preview flags to enable in Codesphere installation (optional) (default [openfga-authz,cluster-admin,secret-management,sub-path-mount,workspace-ssh,virtual-machines]) --project-name string Unique GCP Project Name (required) --project-ttl string Time to live for the GCP project. Cleanup workflows will remove it afterwards. (default: 2 hours) (default "2h") --prometheus-remote-write-password string Prometheus remote write password stored in the generated vault (optional) diff --git a/docs/oms_beta_bootstrap-gcp_restart-vms.md b/docs/oms_beta_bootstrap-gcp_restart-vms.md index 9eeb152ef..0f7236245 100644 --- a/docs/oms_beta_bootstrap-gcp_restart-vms.md +++ b/docs/oms_beta_bootstrap-gcp_restart-vms.md @@ -26,6 +26,9 @@ $ oms beta bootstrap-gcp restart-vms --name jumpbox # Restart a specific k0s node $ oms beta bootstrap-gcp restart-vms --name k0s-1 +# Restart a node of the second data center of a --multi-dc bootstrap +$ oms beta bootstrap-gcp restart-vms --name k0s-1-dc2 + # Restart all VMs with explicit project and zone $ oms beta bootstrap-gcp restart-vms --project-id my-project --zone us-central1-a diff --git a/docs/oms_beta_bootstrap-local.md b/docs/oms_beta_bootstrap-local.md index 4a78c9a07..9551845c5 100644 --- a/docs/oms_beta_bootstrap-local.md +++ b/docs/oms_beta_bootstrap-local.md @@ -16,25 +16,26 @@ oms beta bootstrap-local [flags] ### Options ``` - --argocd After infra setup: install ArgoCD, update the OCI pull secret, and install pc-apps from the BOM version (default true) - --base-domain string Base domain for Codesphere (default "cs.local") - --feature-flags stringArray Feature flags to enable in Codesphere installation (optional) - -h, --help help for bootstrap-local - --install-config string Path to install config file (default: /config.yaml) - --install-dir string Directory for config, secrets, and bundle files (default ".installer") - --install-hash string Codesphere package hash (required when install-version is set) - --install-local string Path to a local installer package (tar.gz or unpacked directory) - --install-version string Codesphere version to install (downloaded from the OMS portal) - --internal-flags stringArray Internal flags to enable in Codesphere installation (optional) (default [headless-services,vcluster,custom-service-image,ms-in-ls]) - --k0s Use k0s-specific configuration (required to deploy to k0s clusters) - --pod-cidr string Service CIDR of the Kubernetes cluster. If not specified, OMS will try to determine it. - --preview-flags stringArray Preview flags to enable in Codesphere installation (optional) (default [openfga-authz,cluster-admin,secret-management,sub-path-mount,workspace-ssh]) - --profile string Profile to apply to the install config like resources (supported: dev, minimal, prod) (default "dev") - --registry-url string OCI registry URL used for the ArgoCD helm pull secret (only relevant with --argocd) (default "oci://ghcr.io/codesphere-cloud/charts") - --registry-user string Custom Registry username - --secrets-file string Path to secrets file (default: /prod.vault.yaml) - --service-cidr string Service CIDR of the Kubernetes cluster. If not specified, OMS will try to determine it. - -y, --yes Auto-approve the local bootstrapping warning prompt + --base-domain string Base domain for Codesphere (default "cs.local") + --ceph-device-filter string Regular expression selecting Ceph block devices by name + --ceph-device-path-filter string Regular expression selecting Ceph block devices by path + --feature-flags stringArray Feature flags to enable in Codesphere installation (optional) + -h, --help help for bootstrap-local + --install-config string Path to install config file (default: /config.yaml) + --install-dir string Directory for config, secrets, and bundle files (default ".installer") + --install-hash string Optional Codesphere package hash used to select a specific build + --install-local string Path to a local installer package (tar.gz or unpacked directory) + --install-version string Codesphere version to install (downloaded from the OMS portal) + --internal-flags stringArray Internal flags to enable in Codesphere installation (optional) (default [headless-services,vcluster,custom-service-image,ms-in-ls]) + --k0s Use k0s-specific configuration (required to deploy to k0s clusters) + --pod-cidr string Service CIDR of the Kubernetes cluster. If not specified, OMS will try to determine it. + --preview-flags stringArray Preview flags to enable in Codesphere installation (optional) (default [openfga-authz,cluster-admin,secret-management,sub-path-mount,workspace-ssh,virtual-machines]) + --profile string Profile to apply to the install config like resources (supported: dev, minimal, prod) (default "dev") + --registry-url string OCI registry URL used for the ArgoCD helm pull secret (default "oci://ghcr.io/codesphere-cloud/charts") + --registry-user string Custom Registry username + --secrets-file string Path to secrets file (default: /prod.vault.yaml) + --service-cidr string Service CIDR of the Kubernetes cluster. If not specified, OMS will try to determine it. + -y, --yes Auto-approve the local bootstrapping warning prompt ``` ### SEE ALSO diff --git a/docs/oms_beta_vault-secret.md b/docs/oms_beta_vault-secret.md index 268a8d0d4..d05e1257d 100644 --- a/docs/oms_beta_vault-secret.md +++ b/docs/oms_beta_vault-secret.md @@ -1,11 +1,11 @@ ## oms beta vault-secret -Create a Kubernetes secret from a SOPS-encrypted vault file +Create a Kubernetes secret from a vault file ### Synopsis -Create a Kubernetes secret from a SOPS-encrypted prod.vault.yaml file. -Reads the encrypted vault file, decrypts it using the age key, and creates a Kubernetes secret +Create a Kubernetes secret from a prod.vault.yaml file. +Loads the selected vault type and creates a Kubernetes secret with all the vault entries as key-value pairs in the target cluster. ``` @@ -26,11 +26,12 @@ $ oms vault-secret --vault-file prod.vault.yaml --age-key /path/to/age_key.txt - ### Options ``` - --age-key string Path to the age key file (optional, will use defaults if not provided) + --age-key string Path to the age key file (required for sops unless an age key environment variable is set) -h, --help help for vault-secret --namespace string Kubernetes namespace where the secret will be created (default "codesphere") --secret-name string Name of the Kubernetes secret to create (default "cs-vault") - --vault-file string Path to the SOPS-encrypted vault file (required) + --vault-file string Path to the vault file (required) + --vault-type string Vault storage type (sops or plain) (default "sops") ``` ### SEE ALSO diff --git a/docs/oms_copy.md b/docs/oms_copy.md new file mode 100644 index 000000000..1bec67719 --- /dev/null +++ b/docs/oms_copy.md @@ -0,0 +1,20 @@ +## oms copy + +Copy resources between locations + +### Synopsis + +Copy resources managed by OMS between locations, +e.g. package container images and OCI Helm charts between registries. + +### Options + +``` + -h, --help help for copy +``` + +### SEE ALSO + +* [oms](oms.md) - Codesphere Operations Management System (OMS) +* [oms copy package](oms_copy_package.md) - Copy all images and Helm charts from an installer package + diff --git a/docs/oms_copy_package.md b/docs/oms_copy_package.md new file mode 100644 index 000000000..884df9bb4 --- /dev/null +++ b/docs/oms_copy_package.md @@ -0,0 +1,47 @@ +## oms copy package + +Copy all images and Helm charts from an installer package + +### Synopsis + +Read all container images and OCI Helm charts from an installer package BOM +and copy them to another registry. + +Use --package for a local installer package or --version to download one +from the OMS portal. The source repository paths are preserved below --dest. + +``` +oms copy package [flags] +``` + +### Examples + +``` +# Copy artifacts from a local package +$ oms copy package --package codesphere-v1.70.0-installer-lite.tar.gz --dest registry.example.com/mirror + +# Download an upstream package and copy without prompting +$ oms copy package --version codesphere-v1.70.0 --dest registry.example.com/mirror --yes + +``` + +### Options + +``` + --dest string Destination registry or repository prefix + -f, --file string Installer artifact to download for an upstream package (default "installer-lite.tar.gz") + --force Re-extract the installer package + -H, --hash string Build hash used to disambiguate an upstream package version + -h, --help help for package + --insecure Allow image references to be fetched without TLS + -p, --package string Path to a local installer package + --show-artifacts Print the source and destination of every artifact to copy + -v, --verbose Enable debug logs + -V, --version string Codesphere package version to download from the OMS portal + -y, --yes Copy without prompting for confirmation +``` + +### SEE ALSO + +* [oms copy](oms_copy.md) - Copy resources between locations + diff --git a/docs/oms_init_install-config.md b/docs/oms_init_install-config.md index ee0db9690..342c45beb 100644 --- a/docs/oms_init_install-config.md +++ b/docs/oms_init_install-config.md @@ -57,6 +57,7 @@ $ oms init install-config --validate -c config.yaml --vault prod.vault.yaml --acme-enabled Enable ACME certificate issuer --acme-issuer-name string Name for the ACME ClusterIssuer (default "acme-issuer") --acme-server string ACME server URL (default "https://acme-v02.api.letsencrypt.org/directory") + --age-key string Path to the age private key (required for sops unless SOPS_AGE_KEY or SOPS_AGE_KEY_FILE is set) --ansible-inventory string Path to Ansible inventory file to import host information from --ceph-csi-kubelet-dir string Directory of kubelet for ceph csi. Required for some cloud providers --ceph-nodes-subnet string CIDR subnet for ceph nodes @@ -71,6 +72,8 @@ $ oms init install-config --validate -c config.yaml --vault prod.vault.yaml --interactive Enable interactive prompting (when true, other config flags are ignored) (default true) --k8s-control-plane strings K8s control plane IPs (comma-separated) --k8s-managed Use Codesphere-managed Kubernetes (default true) + --k8s-pod-cidr string Pod CIDR (required when --k8s-managed=false) + --k8s-service-cidr string Service CIDR (required when --k8s-managed=false) --openbao-engine string Engine for OpenBao (default "cs-secrets-engine") --openbao-password string Password for OpenBao authentication --openbao-uri string URI for OpenBao (e.g., https://openbao.example.com) @@ -90,6 +93,7 @@ $ oms init install-config --validate -c config.yaml --vault prod.vault.yaml --secrets-dir string Secrets base directory (default "/root/secrets") --validate Validate existing config files instead of creating new ones --vault string Output file path for prod.vault.yaml (default "prod.vault.yaml") + --vault-type string Vault storage type (sops or plain) (default "sops") --with-comments Add helpful comments to the generated YAML files ``` diff --git a/docs/oms_install_codesphere.md b/docs/oms_install_codesphere.md index dcf6c2148..8c6488ce6 100644 --- a/docs/oms_install_codesphere.md +++ b/docs/oms_install_codesphere.md @@ -38,9 +38,9 @@ $ oms install codesphere -p codesphere-v1.2.3-installer-lite.tar.gz -k github.com/Azure/azure-sdk-for-go v46.4.0+incompatible + github.com/Azure/azure-sdk-for-go => github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // argo-cd's go.mod resolves its nested gitops-engine module through a local // directory replace, which does not carry over to consumers, so pin it here. // The commit tagged for argo-cd v3.4.6 still imports the autoscaling v2beta* @@ -20,51 +20,51 @@ replace ( // TODO: remove this replace once https://github.com/libopenstorage/secrets/pull/83 is merged github.com/libopenstorage/secrets => github.com/rook/secrets v0.0.0-20240315053144-3195f6906937 github.com/portworx/sched-ops => github.com/portworx/sched-ops v0.20.4-openstorage-rc3 - k8s.io/kubernetes => k8s.io/kubernetes v1.36.3 + k8s.io/kubernetes => k8s.io/kubernetes v1.36.4 ) require ( cloud.google.com/go/artifactregistry v1.26.0 - cloud.google.com/go/compute v1.66.0 + cloud.google.com/go/compute v1.68.0 cloud.google.com/go/iam v1.13.0 cloud.google.com/go/resourcemanager v1.16.0 cloud.google.com/go/serviceusage v1.15.0 - filippo.io/age v1.3.1 + filippo.io/age v1.3.2 + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Masterminds/semver/v3 v3.5.0 + github.com/argoproj/argo-cd/v3 v3.5.3 github.com/cloudnative-pg/cloudnative-pg v1.30.0 - github.com/codesphere-cloud/cs-go v1.20.0 + github.com/codesphere-cloud/cs-go v1.40.0 github.com/creativeprojects/go-selfupdate v1.6.0 + github.com/distribution/reference v0.6.0 github.com/getsops/sops/v3 v3.13.3 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/go-containerregistry v0.22.1 github.com/jedib0t/go-pretty/v6 v6.8.3 + github.com/lib/pq v1.12.3 github.com/lithammer/shortuuid v3.0.0+incompatible - github.com/onsi/ginkgo/v2 v2.32.1 - github.com/onsi/gomega v1.42.1 + github.com/onsi/ginkgo/v2 v2.32.2 + github.com/onsi/gomega v1.43.0 github.com/pkg/sftp v1.13.11 + github.com/rook/rook/pkg/apis v0.0.0-20260915091545-fb2d518f653f github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 go.yaml.in/yaml/v3 v3.0.5 - golang.org/x/crypto v0.55.0 - golang.org/x/mod v0.39.0 - golang.org/x/oauth2 v0.36.0 - golang.org/x/term v0.45.0 - google.golang.org/api v0.293.0 - google.golang.org/grpc v1.83.0 + golang.org/x/crypto v0.57.0 + golang.org/x/mod v0.41.0 + golang.org/x/oauth2 v0.37.0 + golang.org/x/term v0.46.0 + google.golang.org/api v0.298.0 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - helm.sh/helm/v4 v4.2.3 - k8s.io/api v0.36.3 - k8s.io/apimachinery v0.36.3 + helm.sh/helm/v4 v4.3.0 + k8s.io/api v0.37.0 + k8s.io/apimachinery v0.37.0 k8s.io/client-go v12.0.0+incompatible k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 sigs.k8s.io/controller-runtime v0.24.1 -) - -require ( - github.com/DATA-DOG/go-sqlmock v1.5.2 - github.com/argoproj/argo-cd/v3 v3.5.0 - github.com/google/go-github/v74 v74.0.0 - github.com/lib/pq v1.12.3 - github.com/rook/rook/pkg/apis v0.0.0-20260811210519-a4c28bc84816 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -72,27 +72,31 @@ require ( 4d63.com/gochecknoglobals v0.2.2 // indirect al.essio.dev/pkg/shellescape v1.6.0 // indirect cel.dev/expr v0.25.2 // indirect - charm.land/lipgloss/v2 v2.0.5 // indirect + charm.land/lipgloss/v2 v2.0.6 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.23.0 // indirect + cloud.google.com/go/auth v0.23.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/kms v1.32.0 // indirect + cloud.google.com/go/kms v1.33.0 // indirect cloud.google.com/go/longrunning v1.2.0 // indirect cloud.google.com/go/monitoring v1.30.0 // indirect cloud.google.com/go/storage v1.63.1 // indirect code.gitea.io/sdk/gitea v0.25.1 // indirect codeberg.org/chavacava/garif v0.2.1 // indirect codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect + cyphar.com/go-pathrs v0.2.5 // indirect dario.cat/mergo v1.0.2 // indirect dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect + dev.gaijin.team/go/exhaustruct/v5 v5.0.3 // indirect dev.gaijin.team/go/golib v0.8.1 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + filippo.io/hpke v0.4.0 // indirect github.com/42wim/httpsig v1.2.4 // indirect github.com/4meepo/tagalign v1.4.3 // indirect github.com/Abirdcfly/dupword v0.1.8 // indirect github.com/AdminBenni/iota-mixing v1.0.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect - github.com/AlwxSin/noinlineerr v1.0.5 // indirect + github.com/AlwxSin/noinlineerr v1.0.6 // indirect github.com/Antonboom/errname v1.1.2 // indirect github.com/Antonboom/nilnil v1.1.2 // indirect github.com/Antonboom/testifylint v1.6.4 // indirect @@ -114,18 +118,21 @@ require ( github.com/Azure/go-autorest/tracing v0.6.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect + github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect github.com/Djarvur/go-err113 v0.1.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.58.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.58.0 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect - github.com/alecthomas/chroma/v2 v2.26.1 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect github.com/alexkohler/nakedret/v2 v2.0.6 // indirect github.com/alexkohler/prealloc v1.1.0 // indirect @@ -133,33 +140,37 @@ require ( github.com/alingse/asasalint v0.0.11 // indirect github.com/alingse/nilnesserr v0.2.0 // indirect github.com/anchore/go-macholibre v0.1.0 // indirect + github.com/argoproj/argo-cd/gitops-engine v0.7.1-0.20250908182407-97ad5b59a627 // indirect + github.com/argoproj/pkg/v2 v2.0.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect github.com/ashanbrown/makezero/v2 v2.2.1 // indirect github.com/atc0005/go-teams-notify/v2 v2.14.0 // indirect github.com/avast/retry-go/v4 v4.7.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.43.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.31 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect + github.com/avast/retry-go/v5 v5.0.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.43.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.37 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.36 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect github.com/aws/aws-sdk-go-v2/service/ecr v1.58.4 // indirect github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.39.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.54.1 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect - github.com/aws/smithy-go v1.27.4 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 // indirect + github.com/aws/smithy-go v1.27.8 // indirect github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -168,10 +179,14 @@ require ( github.com/blacktop/go-macho v1.1.281 // indirect github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/blang/semver v3.5.1+incompatible // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect github.com/bluesky-social/indigo v0.0.0-20260611225325-2e8287f2f1bb // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/bombsimon/logrusr/v4 v4.1.0 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect - github.com/bombsimon/wsl/v5 v5.8.0 // indirect + github.com/bombsimon/wsl/v5 v5.9.0 // indirect + github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 // indirect github.com/breml/bidichk v0.3.3 // indirect github.com/breml/errchkjson v0.4.1 // indirect github.com/brunoga/deep v1.3.1 // indirect @@ -182,17 +197,21 @@ require ( github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect github.com/caarlos0/go-version v0.2.2 // indirect github.com/caarlos0/log v0.6.2 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect github.com/catenacyber/perfsprint v0.10.1 // indirect github.com/cavaliergopher/cpio v1.0.1 // indirect github.com/ccojocar/zxcvbn-go v1.0.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.3 // indirect + github.com/chainguard-dev/git-urls v1.0.2 // indirect github.com/charithe/durationcheck v0.0.11 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/fang v1.0.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -200,74 +219,98 @@ require ( github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.4 // indirect + github.com/cloudnative-pg/barman-cloud v0.5.1 // indirect + github.com/cloudnative-pg/cnpg-i v0.5.0 // indirect + github.com/cloudnative-pg/machinery v0.5.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containernetworking/cni v1.2.3 // indirect github.com/coreos/go-oidc/v3 v3.20.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/daixiang0/gci v0.14.0 // indirect github.com/dave/dst v0.27.4 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dghubble/go-twitter v0.0.0-20221104224141-912508c3888b // indirect github.com/dghubble/oauth1 v0.7.3 // indirect github.com/dghubble/sling v1.4.2 // indirect github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c // indirect github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea // indirect github.com/dimchansky/utfbom v1.1.1 // indirect - github.com/distribution/reference v0.6.0 - github.com/docker/cli v29.6.2+incompatible // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.2 // indirect + github.com/docker/cli v29.7.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/extism/go-sdk v1.7.1 // indirect + github.com/fatih/camelcase v1.0.0 // indirect github.com/fatih/color v1.19.0 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/firefart/nonamedreturns v1.0.7 // indirect + github.com/firefart/nonamedreturns v1.0.8 // indirect + github.com/fluxcd/cli-utils v1.2.2 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e // indirect github.com/ghostiam/protogetter v0.3.21 // indirect github.com/github/smimesign v0.2.0 // indirect - github.com/go-critic/go-critic v0.14.3 // indirect + github.com/go-critic/go-critic v0.14.4 // indirect + github.com/go-errors/errors v1.5.1 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.1 // indirect github.com/go-git/go-git/v5 v5.19.2 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.25.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/analysis v0.25.5 // indirect github.com/go-openapi/errors v0.22.8 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/loads v0.24.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/loads v0.25.0 // indirect github.com/go-openapi/runtime v0.32.3 // indirect - github.com/go-openapi/spec v0.22.6 // indirect - github.com/go-openapi/strfmt v0.26.3 // indirect - github.com/go-openapi/swag v0.26.1 // indirect - github.com/go-openapi/swag/cmdutils v0.26.1 // indirect - github.com/go-openapi/swag/conv v0.26.1 // indirect - github.com/go-openapi/swag/fileutils v0.26.1 // indirect - github.com/go-openapi/swag/jsonname v0.26.1 // indirect - github.com/go-openapi/swag/jsonutils v0.26.1 // indirect - github.com/go-openapi/swag/loading v0.26.1 // indirect - github.com/go-openapi/swag/mangling v0.26.1 // indirect - github.com/go-openapi/swag/netutils v0.26.1 // indirect - github.com/go-openapi/swag/stringutils v0.26.1 // indirect - github.com/go-openapi/swag/typeutils v0.26.1 // indirect - github.com/go-openapi/swag/yamlutils v0.26.1 // indirect - github.com/go-openapi/validate v0.26.0 // indirect + github.com/go-openapi/runtime/server-middleware v0.32.3 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/strfmt v0.27.0 // indirect + github.com/go-openapi/swag v0.27.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.1 // indirect + github.com/go-openapi/swag/conv v0.27.3 // indirect + github.com/go-openapi/swag/fileutils v0.27.3 // indirect + github.com/go-openapi/swag/jsonutils v0.27.3 // indirect + github.com/go-openapi/swag/loading v0.27.3 // indirect + github.com/go-openapi/swag/mangling v0.27.3 // indirect + github.com/go-openapi/swag/netutils v0.27.1 // indirect + github.com/go-openapi/swag/pools v0.27.3 // indirect + github.com/go-openapi/swag/stringutils v0.27.3 // indirect + github.com/go-openapi/swag/typeutils v0.27.3 // indirect + github.com/go-openapi/swag/yamlutils v0.27.3 // indirect + github.com/go-openapi/validate v0.26.1 // indirect + github.com/go-redis/cache/v9 v9.0.0 // indirect github.com/go-restruct/restruct v1.2.0-alpha // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -282,31 +325,45 @@ require ( github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golangci/asciicheck v0.5.0 // indirect + github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect github.com/golangci/go-printf-func-name v0.1.1 // indirect - github.com/golangci/golangci-lint/v2 v2.12.2 // indirect + github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect + github.com/golangci/golangci-lint/v2 v2.13.2 // indirect github.com/golangci/golines v0.15.0 // indirect github.com/golangci/misspell v0.8.0 // indirect github.com/golangci/plugin-module-register v0.1.2 // indirect github.com/golangci/revgrep v0.8.0 // indirect + github.com/golangci/rowserrcheck v0.0.0-20260602201336-0ec5bd2741d7 // indirect github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect - github.com/google/go-containerregistry v0.21.7 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-github/v69 v69.2.0 // indirect + github.com/google/go-github/v86 v86.0.0 // indirect + github.com/google/go-github/v88 v88.0.0 // indirect + github.com/google/go-github/v89 v89.0.0 // indirect + github.com/google/go-licenses/v2 v2.0.1 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/ko v0.19.1 // indirect + github.com/google/licenseclassifier/v2 v2.0.0 // indirect + github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect github.com/google/rpmpack v0.7.1 // indirect github.com/google/s2a-go v0.1.9 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 // indirect github.com/google/wire v0.7.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect - github.com/googleapis/gax-go/v2 v2.23.0 // indirect + github.com/googleapis/gax-go/v2 v2.24.0 // indirect github.com/gordonklaus/ineffassign v0.2.0 // indirect github.com/goreleaser/chglog v0.7.4 // indirect github.com/goreleaser/fileglob v1.4.0 // indirect - github.com/goreleaser/goreleaser/v2 v2.17.1 // indirect + github.com/goreleaser/go-shellwords v1.0.13 // indirect + github.com/goreleaser/goreleaser/v2 v2.18.0 // indirect github.com/goreleaser/nfpm/v2 v2.47.0 // indirect github.com/goreleaser/quill v0.0.0-20260630015114-8310f3e9a321 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -314,19 +371,30 @@ require ( github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/nilerr v0.1.2 // indirect + github.com/gosuri/uitable v0.0.4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.23.0 // indirect + github.com/hashicorp/vault/api/auth/approle v0.8.0 // indirect + github.com/hashicorp/vault/api/auth/kubernetes v0.8.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect github.com/ipfs/bbloom v0.1.0 // indirect github.com/ipfs/boxo v0.41.0 // indirect @@ -342,14 +410,19 @@ require ( github.com/ipfs/go-metrics-interface v0.3.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jedisct1/go-minisign v0.0.0-20260527172527-a09352b57a22 // indirect - github.com/jgautheron/goconst v1.10.2 // indirect + github.com/jgautheron/goconst v1.11.0 // indirect github.com/jjti/go-spancheck v0.6.5 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/julz/importas v0.2.0 // indirect + github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7 // indirect github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/kisielk/errcheck v1.20.0 // indirect github.com/kkHAIKE/contextcheck v1.1.6 // indirect - github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/compress v1.19.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect @@ -360,10 +433,13 @@ require ( github.com/knadh/koanf/providers/structs v1.0.0 // indirect github.com/knadh/koanf/v2 v2.3.5 // indirect github.com/kr/fs v0.1.0 // indirect + github.com/kube-object-storage/lib-bucket-provisioner v0.0.0-20260420161730-5164e3746489 // indirect + github.com/kubernetes-csi/external-snapshotter/client/v8 v8.6.0 // indirect github.com/kulti/thelper v0.7.1 // indirect github.com/kunwardeep/paralleltest v1.0.15 // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/lasiar/canonicalheader v1.1.2 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/ldez/exptostd v0.4.5 // indirect github.com/ldez/gomoddirectives v0.9.0 // indirect github.com/ldez/grignotin v0.10.1 // indirect @@ -371,7 +447,9 @@ require ( github.com/ldez/tagliatelle v0.7.2 // indirect github.com/ldez/usetesting v0.5.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/libopenstorage/secrets v0.0.0-20240416031220-a17cf7f72c6c // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/macabu/inamedparam v0.2.0 // indirect github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect github.com/manuelarte/funcorder v0.6.0 // indirect @@ -381,14 +459,23 @@ require ( github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-mastodon v0.0.13 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mgechev/revive v1.15.0 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.1 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/moby/term v0.5.2 // indirect - github.com/modelcontextprotocol/registry v1.8.0 // indirect + github.com/modelcontextprotocol/registry v1.8.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/moricho/tparallel v0.3.2 // indirect github.com/mr-tron/base58 v1.3.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect @@ -405,18 +492,29 @@ require ( github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect - github.com/nunnatsa/ginkgolinter v0.23.0 // indirect + github.com/nunnatsa/ginkgolinter v0.24.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/openshift/api v0.0.0-20241216151652-de9de05a8e43 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/otiai10/copy v1.14.1 // indirect + github.com/otiai10/mint v1.6.3 // indirect + github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polydawn/refmt v0.90.0 // indirect + github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.92.0 // indirect github.com/prometheus/client_golang v1.24.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect @@ -424,45 +522,59 @@ require ( github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect github.com/raeperd/recvcheck v0.3.0 // indirect + github.com/redis/go-redis/v9 v9.20.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.2-0.20210106135023-bc59245fe10e // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/rs/zerolog v1.35.1 // indirect + github.com/rubenv/sql-migrate v1.8.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryancurrah/gomodguard v1.4.1 // indirect + github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect - github.com/securego/gosec/v2 v2.27.1 // indirect + github.com/securego/gosec/v2 v2.28.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/sigstore/cosign/v3 v3.1.1 // indirect github.com/sigstore/protobuf-specs v0.5.1 // indirect github.com/sigstore/rekor v1.5.2 // indirect github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/sigstore v1.10.8 // indirect github.com/sigstore/sigstore-go v1.2.1 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sirupsen/logrus v1.10.1 // indirect github.com/sivchari/containedctx v1.0.3 // indirect github.com/skeema/knownhosts v1.3.2 // indirect - github.com/slack-go/slack v0.27.0 // indirect + github.com/slack-go/slack v0.29.0 // indirect github.com/sonatard/noctx v0.5.1 // indirect github.com/sourcegraph/go-diff v0.8.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetafro/godot v1.5.6 // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect + github.com/thoas/go-funk v0.9.3 // indirect github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect @@ -470,29 +582,39 @@ require ( github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e // indirect github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/ulikunitz/xz v0.5.16 // indirect github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect github.com/uudashr/gocognit v1.2.1 // indirect - github.com/uudashr/iface v1.5.0 // indirect - github.com/vektra/mockery/v3 v3.7.2 // indirect + github.com/uudashr/iface v1.5.1 // indirect + github.com/vektra/mockery/v3 v3.7.4 // indirect + github.com/vmihailenco/go-tinylfu v0.2.2 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 // indirect github.com/whyrusleeping/cbor-gen v0.3.1 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xen0n/gosmopolitan v1.3.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect go-simpler.org/musttag v0.14.0 // indirect go-simpler.org/sloglint v0.12.0 // indirect go.augendre.info/arangolint v0.4.0 // indirect - go.augendre.info/fatcontext v0.9.0 // indirect + go.augendre.info/fatcontext v0.10.0 // indirect + go.digitalxero.dev/go-msix v0.3.1 // indirect + go.mozilla.org/pkcs7 v0.9.0 // indirect + go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect @@ -502,185 +624,57 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect gocloud.dev v0.46.0 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect - golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.49.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto v0.0.0-20260720171339-e059f2f05d78 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260720171339-e059f2f05d78 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/mail.v2 v2.3.1 // indirect gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - honnef.co/go/tools v0.7.0 // indirect - lukechampine.com/blake3 v1.4.1 // indirect - mvdan.cc/gofumpt v0.10.0 // indirect - mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect - sigs.k8s.io/kind v0.32.0 // indirect - sigs.k8s.io/yaml v1.6.0 - software.sslmate.com/src/go-pkcs12 v0.7.3 // indirect -) - -require ( - cyphar.com/go-pathrs v0.2.2 // indirect - filippo.io/edwards25519 v1.2.0 // indirect - filippo.io/hpke v0.4.0 // indirect - github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect - github.com/MakeNowJust/heredoc v1.0.0 // indirect - github.com/Masterminds/squirrel v1.5.4 // indirect - github.com/argoproj/argo-cd/gitops-engine v0.7.1-0.20250908182407-97ad5b59a627 // indirect - github.com/argoproj/pkg/v2 v2.0.1 // indirect - github.com/avast/retry-go/v5 v5.0.0 // indirect - github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect - github.com/bombsimon/logrusr/v4 v4.1.0 // indirect - github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 // indirect - github.com/casbin/casbin/v2 v2.135.0 // indirect - github.com/casbin/govaluate v1.10.0 // indirect - github.com/chai2010/gettext-go v1.0.3 // indirect - github.com/chainguard-dev/git-urls v1.0.2 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudnative-pg/barman-cloud v0.5.1 // indirect - github.com/cloudnative-pg/cnpg-i v0.5.0 // indirect - github.com/cloudnative-pg/machinery v0.5.0 // indirect - github.com/containernetworking/cni v1.2.3 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/dlclark/regexp2 v1.12.0 // indirect - github.com/dlclark/regexp2/v2 v2.2.2 // indirect - github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/emicklei/go-restful/v3 v3.13.0 // indirect - github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect - github.com/extism/go-sdk v1.7.1 // indirect - github.com/fatih/camelcase v1.0.0 // indirect - github.com/fluxcd/cli-utils v1.2.1 // indirect - github.com/fxamacker/cbor/v2 v2.9.2 // indirect - github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e // indirect - github.com/go-errors/errors v1.5.1 // indirect - github.com/go-gorp/gorp/v3 v3.1.0 // indirect - github.com/go-logr/logr v1.4.4 // indirect - github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/runtime/server-middleware v0.32.3 // indirect - github.com/go-redis/cache/v9 v9.0.0 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect - github.com/golangci/rowserrcheck v0.0.0-20260602201336-0ec5bd2741d7 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/go-github/v69 v69.2.0 // indirect - github.com/google/go-github/v86 v86.0.0 // indirect - github.com/google/go-github/v88 v88.0.0 // indirect - github.com/google/go-github/v89 v89.0.0 // indirect - github.com/google/go-licenses/v2 v2.0.1 // indirect - github.com/google/go-querystring v1.2.0 // indirect - github.com/google/licenseclassifier/v2 v2.0.0 // indirect - github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/goreleaser/go-shellwords v1.0.13 // indirect - github.com/gosuri/uitable v0.0.4 // indirect - github.com/hashicorp/go-rootcerts v1.0.2 // indirect - github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect - github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect - github.com/hashicorp/go-sockaddr v1.0.7 // indirect - github.com/hashicorp/hcl v1.0.1-vault-7 // indirect - github.com/hashicorp/vault/api v1.23.0 // indirect - github.com/hashicorp/vault/api/auth/approle v0.8.0 // indirect - github.com/hashicorp/vault/api/auth/kubernetes v0.8.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jmoiron/sqlx v1.4.0 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect - github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect - github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/kube-object-storage/lib-bucket-provisioner v0.0.0-20221122204822-d1a8c34382f1 // indirect - github.com/kubernetes-csi/external-snapshotter/client/v8 v8.6.0 // indirect - github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect - github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect - github.com/libopenstorage/secrets v0.0.0-20240416031220-a17cf7f72c6c // indirect - github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect - github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect - github.com/moby/moby/api v1.55.0 // indirect - github.com/moby/moby/client v0.5.0 // indirect - github.com/moby/spdystream v0.5.1 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect - github.com/oklog/ulid/v2 v2.1.1 // indirect - github.com/openshift/api v0.0.0-20241216151652-de9de05a8e43 // indirect - github.com/otiai10/copy v1.14.1 // indirect - github.com/otiai10/mint v1.6.3 // indirect - github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible // indirect - github.com/pb33f/ordered-map/v2 v2.3.1 // indirect - github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.92.0 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/redis/go-redis/v9 v9.20.1 // indirect - github.com/robfig/cron/v3 v3.0.2-0.20210106135023-bc59245fe10e // indirect - github.com/rogpeppe/go-internal v1.15.0 // indirect - github.com/rubenv/sql-migrate v1.8.1 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect - github.com/ryanuber/go-glob v1.0.0 // indirect - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect - github.com/sigstore/cosign/v3 v3.1.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/objx v0.5.3 // indirect - github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect - github.com/tetratelabs/wazero v1.12.0 // indirect - github.com/thoas/go-funk v0.9.3 // indirect - github.com/ulikunitz/xz v0.5.16 // indirect - github.com/vmihailenco/go-tinylfu v0.2.2 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/x448/float16 v0.8.4 // indirect - github.com/xlab/treeprint v1.2.0 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.digitalxero.dev/go-msix v0.3.1 // indirect - go.mozilla.org/pkcs7 v0.9.0 // indirect - go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/tools v0.48.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apiextensions-apiserver v0.36.2 // indirect - k8s.io/apiserver v0.36.3 // indirect - k8s.io/cli-runtime v0.36.3 // indirect - k8s.io/component-base v0.36.3 // indirect - k8s.io/component-helpers v0.36.3 // indirect + honnef.co/go/tools v0.8.1 // indirect + k8s.io/apiextensions-apiserver v0.37.0 // indirect + k8s.io/apiserver v0.37.0 // indirect + k8s.io/cli-runtime v0.37.0 // indirect + k8s.io/component-base v0.37.0 // indirect + k8s.io/component-helpers v0.36.4 // indirect k8s.io/controller-manager v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-aggregator v0.36.1 // indirect - k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25 // indirect - k8s.io/kubectl v0.36.1 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/kubectl v0.37.0 // indirect k8s.io/kubernetes v1.36.1 // indirect - k8s.io/streaming v0.36.3 // indirect + k8s.io/streaming v0.36.4 // indirect + lukechampine.com/blake3 v1.4.1 // indirect + mvdan.cc/gofumpt v0.11.0 // indirect + mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect oras.land/oras-go/v2 v2.6.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kind v0.32.0 // indirect sigs.k8s.io/kustomize/api v0.21.1 // indirect sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.3 // indirect ) tool ( @@ -692,39 +686,39 @@ tool ( // replaces for kubernetes imports. Generated with hack/update-k8s-imports.sh replace ( - k8s.io/api => k8s.io/api v0.36.3 - k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.36.3 - k8s.io/apimachinery => k8s.io/apimachinery v0.36.3 - k8s.io/apiserver => k8s.io/apiserver v0.36.3 - k8s.io/cli-runtime => k8s.io/cli-runtime v0.36.3 - k8s.io/client-go => k8s.io/client-go v0.36.3 - k8s.io/cloud-provider => k8s.io/cloud-provider v0.36.3 - k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.36.3 - k8s.io/code-generator => k8s.io/code-generator v0.36.3 - k8s.io/component-base => k8s.io/component-base v0.36.3 - k8s.io/component-helpers => k8s.io/component-helpers v0.36.3 - k8s.io/controller-manager => k8s.io/controller-manager v0.36.3 - k8s.io/cri-api => k8s.io/cri-api v0.36.3 - k8s.io/cri-client => k8s.io/cri-client v0.36.3 - k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.36.3 - k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.36.3 - k8s.io/endpointslice => k8s.io/endpointslice v0.36.3 - k8s.io/externaljwt => k8s.io/externaljwt v0.36.3 - k8s.io/kms => k8s.io/kms v0.36.3 - k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.36.3 - k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.36.3 - k8s.io/kube-proxy => k8s.io/kube-proxy v0.36.3 - k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.36.3 - k8s.io/kubectl => k8s.io/kubectl v0.36.3 - k8s.io/kubelet => k8s.io/kubelet v0.36.3 - k8s.io/metrics => k8s.io/metrics v0.36.3 - k8s.io/mount-utils => k8s.io/mount-utils v0.36.3 - k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.36.3 - k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.36.3 - k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.36.3 - k8s.io/sample-controller => k8s.io/sample-controller v0.36.3 + k8s.io/api => k8s.io/api v0.36.4 + k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.36.4 + k8s.io/apimachinery => k8s.io/apimachinery v0.36.4 + k8s.io/apiserver => k8s.io/apiserver v0.36.4 + k8s.io/cli-runtime => k8s.io/cli-runtime v0.36.4 + k8s.io/client-go => k8s.io/client-go v0.36.4 + k8s.io/cloud-provider => k8s.io/cloud-provider v0.36.4 + k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.36.4 + k8s.io/code-generator => k8s.io/code-generator v0.36.4 + k8s.io/component-base => k8s.io/component-base v0.36.4 + k8s.io/component-helpers => k8s.io/component-helpers v0.36.4 + k8s.io/controller-manager => k8s.io/controller-manager v0.36.4 + k8s.io/cri-api => k8s.io/cri-api v0.36.4 + k8s.io/cri-client => k8s.io/cri-client v0.36.4 + k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.36.4 + k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.36.4 + k8s.io/endpointslice => k8s.io/endpointslice v0.36.4 + k8s.io/externaljwt => k8s.io/externaljwt v0.36.4 + k8s.io/kms => k8s.io/kms v0.36.4 + k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.36.4 + k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.36.4 + k8s.io/kube-proxy => k8s.io/kube-proxy v0.36.4 + k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.36.4 + k8s.io/kubectl => k8s.io/kubectl v0.36.4 + k8s.io/kubelet => k8s.io/kubelet v0.36.4 + k8s.io/metrics => k8s.io/metrics v0.36.4 + k8s.io/mount-utils => k8s.io/mount-utils v0.36.4 + k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.36.4 + k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.36.4 + k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.36.4 + k8s.io/sample-controller => k8s.io/sample-controller v0.36.4 ) -replace k8s.io/cri-streaming => k8s.io/cri-streaming v0.36.3 +replace k8s.io/cri-streaming => k8s.io/cri-streaming v0.37.0 -replace k8s.io/streaming => k8s.io/streaming v0.36.3 +replace k8s.io/streaming => k8s.io/streaming v0.37.0 diff --git a/go.sum b/go.sum index 2ed460f7f..754502eaa 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.31.0-2023072100362 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.31.0-20230802163732-1c33ebd9ecfa.1/go.mod h1:xafc+XIsTxTy76GJQ1TKgvJWsSugFBqMaN27WhUblew= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0= -c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= -c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cel.dev/expr v0.15.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg= cel.dev/expr v0.16.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg= cel.dev/expr v0.16.1/go.mod h1:AsGA5zb3WruAEQeQng1RZdGEXmBj0jvMWh6l5SnNuC8= @@ -24,8 +24,8 @@ cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= -charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -368,8 +368,8 @@ cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/ cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A= cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM= cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= -cloud.google.com/go/auth v0.23.0 h1:6Gg1CMgpgubRG7DGz5Vf1pcoNo8RfiRiRAPS4crTp54= -cloud.google.com/go/auth v0.23.0/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0= +cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo= +cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0= cloud.google.com/go/auth/oauth2adapt v0.2.1/go.mod h1:tOdK/k+D2e4GEwfBRA48dKNQiDsqIXxLh7VU319eV0g= cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= @@ -692,8 +692,8 @@ cloud.google.com/go/compute v1.29.0/go.mod h1:HFlsDurE5DpQZClAGf/cYh+gxssMhBxBov cloud.google.com/go/compute v1.31.0/go.mod h1:4SCUCDAvOQvMGu4ze3YIJapnY0UQa5+WvJJeYFsQRoo= cloud.google.com/go/compute v1.31.1/go.mod h1:hyOponWhXviDptJCJSoEh89XO1cfv616wbwbkde1/+8= cloud.google.com/go/compute v1.34.0/go.mod h1:zWZwtLwZQyonEvIQBuIa0WvraMYK69J5eDCOw9VZU4g= -cloud.google.com/go/compute v1.66.0 h1:v8pAJeA0xYPe/tJDMcyimdnHZ115LVrw3xCi9ZelTNQ= -cloud.google.com/go/compute v1.66.0/go.mod h1:h1O3BCv0Zd0/8rZ6PGx8aRBhKBtDC0AuvURtWg1hLLE= +cloud.google.com/go/compute v1.68.0 h1:+8u41+P7i4Cdzbsna+mxfHl/cTm1HYDS+XmLdf0KcPw= +cloud.google.com/go/compute v1.68.0/go.mod h1:h1O3BCv0Zd0/8rZ6PGx8aRBhKBtDC0AuvURtWg1hLLE= cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU= cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= @@ -1461,8 +1461,8 @@ cloud.google.com/go/kms v1.20.2/go.mod h1:LywpNiVCvzYNJWS9JUcGJSVTNSwPwi0vBAotzD cloud.google.com/go/kms v1.20.4/go.mod h1:gPLsp1r4FblUgBYPOcvI/bUPpdMg2Jm1ZVKU4tQUfcc= cloud.google.com/go/kms v1.20.5/go.mod h1:C5A8M1sv2YWYy1AE6iSrnddSG9lRGdJq5XEdBy28Lmw= cloud.google.com/go/kms v1.21.0/go.mod h1:zoFXMhVVK7lQ3JC9xmhHMoQhnjEDZFoLAr5YMwzBLtk= -cloud.google.com/go/kms v1.32.0 h1:s+rEluaaZKhLVjrIWG7uNBsnWbiitElzNzFGyp6+nIg= -cloud.google.com/go/kms v1.32.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA= +cloud.google.com/go/kms v1.33.0 h1:pG0X78m212b2pv9N4fdMoUO69LuZGQ9kSvn8sHBOFAo= +cloud.google.com/go/kms v1.33.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA= cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE= @@ -2653,20 +2653,22 @@ codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoP codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= contrib.go.opencensus.io/exporter/stackdriver v0.13.15-0.20230702191903-2de6d2748484/go.mod h1:uxw+4/0SiKbbVSD/F2tk5pJTdVcfIBBcsQ8gwcu4X+E= -cyphar.com/go-pathrs v0.2.2 h1:y9w7hxbkr3zEL78Fjzeg4HEhs2xNy+fbwHiHGJJY2Xo= -cyphar.com/go-pathrs v0.2.2/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik= +cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= +dev.gaijin.team/go/exhaustruct/v5 v5.0.3 h1:yOeA7DNjlT8y4yfmN6nWWYYggA13N523YAj9/TXbuTM= +dev.gaijin.team/go/exhaustruct/v5 v5.0.3/go.mod h1:KwtBsX8nHHH1YxhxkpiBq6bfsmw5WnazWpNvJPHgY9Y= dev.gaijin.team/go/golib v0.8.1 h1:JYju4x9BSo+QD/AYeHULVDcvEhiFg8wOi6pT0IaZF5E= dev.gaijin.team/go/golib v0.8.1/go.mod h1:c5fu7t1RSGMxSQgcUYO1sODbzsYnOCXJLmHeNG1Eb+0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20221208032759-85de2813cf6b/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= -filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= -filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c= +filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= @@ -2703,16 +2705,16 @@ github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCg github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= -github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= -github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= +github.com/AlwxSin/noinlineerr v1.0.6 h1:KAvuxunTe9QxvqrFB7nZTdb/7Wzas4AvifslTnG0Ld8= +github.com/AlwxSin/noinlineerr v1.0.6/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= github.com/Antonboom/errname v1.1.2 h1:dxwONZJua3VB8Xh/VaCjqAcqF645sWWv7xj26zy7tdQ= github.com/Antonboom/errname v1.1.2/go.mod h1:YeZIpgLMxT+SNkruGgYkLhzq/9vs3fsolTZegKaKDZI= github.com/Antonboom/nilnil v1.1.2 h1:aNlFuJhaEseXe4fHO3xbjXlSeEiQVYa2lEkWD2s2hAY= github.com/Antonboom/nilnil v1.1.2/go.mod h1:0ynwvphOLmAuMwTNDyBnDZmSwZoDpcFXmUHmzoHH2WA= github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= -github.com/Azure/azure-sdk-for-go v46.4.0+incompatible h1:fCN6Pi+tEiEwFa8RSmtVlFHRXEZ+DJm9gfx/MKqYWw4= -github.com/Azure/azure-sdk-for-go v46.4.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA= @@ -2864,8 +2866,8 @@ github.com/alecthomas/assert/v2 v2.2.2/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhk github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78= -github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE= @@ -2920,8 +2922,8 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/argoproj/argo-cd/gitops-engine v0.0.0-20260728075051-7660efb23b2d h1:/rO/uVUBn8ywSeYGDCXIIpKiCgxZ23+xshH+5UeuLUo= github.com/argoproj/argo-cd/gitops-engine v0.0.0-20260728075051-7660efb23b2d/go.mod h1:RsOM4gdM/lsvAfIuzAhYrnHDTLA1AGooZRzVyxbVT3A= -github.com/argoproj/argo-cd/v3 v3.5.0 h1:iqnPFSKaQ0l3hREMH2Rk55VDcxrvBFaSO0g9bifZKzc= -github.com/argoproj/argo-cd/v3 v3.5.0/go.mod h1:/248vUTcQHNW3fYkaSUc0PkCFA/+mnILl5b6rv+xG6Y= +github.com/argoproj/argo-cd/v3 v3.5.3 h1:klbUb5LOK+oJSBkR0OYGlLkG9IpsPVtSR2idOdaQb2I= +github.com/argoproj/argo-cd/v3 v3.5.3/go.mod h1:/248vUTcQHNW3fYkaSUc0PkCFA/+mnILl5b6rv+xG6Y= github.com/argoproj/pkg/v2 v2.0.1 h1:O/gCETzB/3+/hyFL/7d/VM/6pSOIRWIiBOTb2xqAHvc= github.com/argoproj/pkg/v2 v2.0.1/go.mod h1:sdifF6sUTx9ifs38ZaiNMRJuMpSCBB9GulHfbPgQeRE= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= @@ -2950,88 +2952,88 @@ github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZw github.com/aws/aws-sdk-go-v2 v1.17.5/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= github.com/aws/aws-sdk-go-v2 v1.25.2/go.mod h1:Evoc5AsmtveRt1komDwIsjHFyrP5tDuF1D1U+6z6pNo= github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= -github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= +github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= +github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM= github.com/aws/aws-sdk-go-v2/config v1.18.14/go.mod h1:0pI6JQBHKwd0JnwAZS3VCapLKMO++UL2BOkWwyyzTnA= github.com/aws/aws-sdk-go-v2/config v1.27.4/go.mod h1:zq2FFXK3A416kiukwpsd+rD4ny6JC7QSkp4QdN1Mp2g= github.com/aws/aws-sdk-go-v2/config v1.29.12/go.mod h1:xse1YTjmORlb/6fhkWi8qJh3cvZi4JoVNhc+NbJt4kI= -github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk= -github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= +github.com/aws/aws-sdk-go-v2/config v1.32.37 h1:Ljl7LOJB6ym0liuEl0+TZ3d7f5I8MEZN1Cj9PINlj/g= +github.com/aws/aws-sdk-go-v2/config v1.32.37/go.mod h1:WJ7pe7ZPpmG8Q5kKS53zeypIV4FBGACxmte8Uc6SgUc= github.com/aws/aws-sdk-go-v2/credentials v1.13.14/go.mod h1:85ckagDuzdIOnZRwws1eLKnymJs3ZM1QwVC1XcuNGOY= github.com/aws/aws-sdk-go-v2/credentials v1.17.4/go.mod h1:+30tpwrkOgvkJL1rUZuRLoxcJwtI/OkeBLYnHxJtVe0= github.com/aws/aws-sdk-go-v2/credentials v1.17.65/go.mod h1:4zyjAuGOdikpNYiSGpsGz8hLGmUzlY8pc8r9QQ/RXYQ= -github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36 h1:84s5xMme6ENYEdKG8rsbSFFg/8+lbHBeM9QYSO0gnDk= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36/go.mod h1:c46BLdagDLIswjgt+GeQOslXgeS0E6wCacs5yZbxPGk= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23/go.mod h1:mOtmAg65GT1HIL/HT/PynwPbS+UG0BgCZ6vhkPqnxWo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.2/go.mod h1:iRlGzMix0SExQEviAyptRWRGdYNo3+ufW/lCzvKVTUc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 h1:b5tb+CZItBkydC7r3hTNdSO3pszG1R2EtnA+7TePQPk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37/go.mod h1:ZQ+6SU9X0oz6+7MUCSswv9Mjci4eaqZr21HI2RVy/yA= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 h1:Pn7OsMwBLbkZ6OnCxWHAjf0L/22H8cnhxZC0uPwtMtg= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34/go.mod h1:eToXR/Gk1uqpn04eSmdgVXwfS0WvH8aG4eBFr8ygbpU= -github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5 h1:7ZFdtE1XEHH+58GZU4Mbhq6SO/UbColleDApOtlv3vo= -github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5/go.mod h1:M/qt7xBBXqilBwNmrO1yiu0cywZwQx5aqtKpdJN9J2A= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.14 h1:ZH5pitzSx4Q7UUVVfTAJTxlbhmELBI8aafgF0wWF5l4= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.14/go.mod h1:Wl+WygckBndyBhVf1kOVUCYBtS4KI2pHgcN8jGsYhwE= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29/go.mod h1:Dip3sIGv485+xerzVv24emnjX5Sg88utCL8fwGmCeWg= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.2/go.mod h1:wRQv0nN6v9wDXuWThpovGQjqF1HFdcgWjporw14lS8k= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23/go.mod h1:mr6c4cHC+S/MMkrjtSlG4QA36kOznDep+0fga5L/fGQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.2/go.mod h1:tyF5sKccmDz0Bv4NrstEr+/9YkSPJHrcO7UsUKf7pWM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30/go.mod h1:vsbq62AOBwQ1LJ/GWKFxX8beUEYeRp/Agitrxee2/qM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= github.com/aws/aws-sdk-go-v2/service/ecr v1.58.4 h1:fo6cmbxkKq/OtKUG0sK70fDsYjtKuSkjIQZUJwt24YM= github.com/aws/aws-sdk-go-v2/service/ecr v1.58.4/go.mod h1:7VJFM2lSPHz2I1rRb0a+lbphoOp7hXIgYjGhSTOLY7k= github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.39.6 h1:pI1S5+Z8cfN/fImioNCHCWKFgm49ZeBhnjffJfRWHYA= github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.39.6/go.mod h1:VctLEHQ91HQAWosSGqbNykn4OoxuUVGbE+1SachaXa0= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 h1:5437eMoOwqqQpZn2XJy74mlDCuPYL81texMT3mXqgtU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30/go.mod h1:xfu2m3dOpvW8lj98wQYa8V9ku/Rta59hsbireGzhh3A= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23/go.mod h1:9uPh+Hrz2Vn6oMnQYiUi/zbh3ovbnQk19YKINkQny44= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.2/go.mod h1:Ru7vg1iQ7cR4i7SZ/JTLYN9kaXtbL69UdgG0OQWQxW0= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38 h1:gX8B8y3Ho30B1LPxefDKMi/HZqWEb47U9ogs3DtSG0M= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38/go.mod h1:l5WblZlcmGPe4/O7JY2HO25Z+xqTBvyfTyFbRMf8gYw= github.com/aws/aws-sdk-go-v2/service/kms v1.54.1 h1:aeJAJyvWS3gQ679pJbz8ZdOh3MViD1zvEdoZMVEawbg= github.com/aws/aws-sdk-go-v2/service/kms v1.54.1/go.mod h1:0RXNc6Yf3AvSMldGD6Lcch96Ojlw2TtGnHsqfD/L4u8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2 h1:GNU0/xtPEXMKilJZ/a8BedeuQnvu+Usi6qVm9EFfncc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2/go.mod h1:4jYWUecEsQtE73jPl7p3jrbYXH5ffcR4gegyCygagfg= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 h1:i68sFvXidKlkiSvI7d7Ilc1/UvW4CtBOaivH7jhG4fs= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6/go.mod h1:/h7Obr9WTtzbjTHGASRQwLN7Bupw+TC3x8x7fyx39hE= github.com/aws/aws-sdk-go-v2/service/sso v1.12.3/go.mod h1:jtLIhd+V+lft6ktxpItycqHqiVXrPIRjWIsFIlzMriw= github.com/aws/aws-sdk-go-v2/service/sso v1.20.1/go.mod h1:RsYqzYr2F2oPDdpy+PdhephuZxTfjHQe7SOBcZGoAU8= github.com/aws/aws-sdk-go-v2/service/sso v1.25.2/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 h1:tpfGChmjUmv3W9WlRvy+stwKDTbFFdq8Zk9DbFPrfMU= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6/go.mod h1:CSjiDzmG/lsKkTOYjbkM+duLmRlW+LOxD64Na44ijnI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.3/go.mod h1:zVwRrfdSmbRZWkUkWjOItY7SOalnFnq/Yg2LVPqDjwc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1/go.mod h1:YjAPFn4kGFqKC54VsHs5fn5B6d+PCY2tziEa3U/GB5Y= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 h1:49BBtY68A+KJCQ3a2F3eUe6ROsKucxUdfHKoqorc0wI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6/go.mod h1:ptG2hbs7QltE1GcQY0MpS4bfrc51KCnBXUr7OT1EEfE= github.com/aws/aws-sdk-go-v2/service/sts v1.18.4/go.mod h1:1mKZHLLpDMHTNSYPJ7qrcnCQdHCWsNQaT0xRvq2u80s= github.com/aws/aws-sdk-go-v2/service/sts v1.28.1/go.mod h1:uQ7YYKZt3adCRrdCBREm1CD3efFLOUNH77MrUCvx5oA= github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M= -github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0 h1:JFWXO6QPihCknDdnL6VaQE57km4ZKheHIGd9YiOGcTo= github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0/go.mod h1:046/oLyFlYdAghYQE2yHXi/E//VM5Cf3/dFmA+3CZ0c= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= @@ -3067,8 +3069,8 @@ github.com/bombsimon/logrusr/v4 v4.1.0 h1:uZNPbwusB0eUXlO8hIUwStE6Lr5bLN6IgYgG+7 github.com/bombsimon/logrusr/v4 v4.1.0/go.mod h1:pjfHC5e59CvjTBIU3V3sGhFWFAnsnhOR03TRc6im0l8= github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= -github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= -github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o= +github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfsU= +github.com/bombsimon/wsl/v5 v5.9.0/go.mod h1:kjo4HiAV5FDkHC8/uzJq9mBffEEd6WT/nvN7DoMovDM= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI= @@ -3144,10 +3146,10 @@ github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGy github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= -github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk= -github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb h1:hoqNT54vrpXamSaQe5GxupakGgvvqFmVgmLJjotpHco= github.com/charmbracelet/x/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= @@ -3219,8 +3221,8 @@ github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSU github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/codesphere-cloud/cs-go v1.20.0 h1:MY4lDHxjs6a/48h6VwbatyFJjDnm5n863npipxPep2w= -github.com/codesphere-cloud/cs-go v1.20.0/go.mod h1:hnk7uN0QL0Rs5oFpQv26b5xk5n2b6opxsx/LvegZUgY= +github.com/codesphere-cloud/cs-go v1.40.0 h1:rhEUmUaqgKDTs96pb5vuH6dLHU2I2SQ/ylhNg2i5Few= +github.com/codesphere-cloud/cs-go v1.40.0/go.mod h1:BGqDouinpKU/26ZFo0cH6n5W3MG5YNrOcu+Kstm/j5o= github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -3257,8 +3259,8 @@ github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+f github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/daixiang0/gci v0.14.0 h1:h6AcLqmjIOBgojhtzY2CvBnA6RawPTkBHgtMvYD5YZ8= github.com/daixiang0/gci v0.14.0/go.mod h1:w9E+SWQ4aPQ+xYPUdqitGDoXpT4mayqOfk7Szz2U6wQ= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= @@ -3302,8 +3304,8 @@ github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpf github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= -github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY= +github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= @@ -3413,10 +3415,10 @@ github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= -github.com/firefart/nonamedreturns v1.0.7 h1:gIrbIri68tN9rneOoWzBTA4B+rh5qP5Yq0yILeQwSfo= -github.com/firefart/nonamedreturns v1.0.7/go.mod h1:Kj/SHlG7im9GjmGfGBij361DJmPhOXZhydgHeJT1Xt4= -github.com/fluxcd/cli-utils v1.2.1 h1:ug9CicKW7H9QXnvNDapTSKuryZvWcu4Nw7pRvQa6jDY= -github.com/fluxcd/cli-utils v1.2.1/go.mod h1:cky6M6eHvTQkoPtsuFYLIgAMYdpTCSLoor4IA6vueSw= +github.com/firefart/nonamedreturns v1.0.8 h1:iB32Dl17zJl1zlVEj/WlUWgx0HiRyQ85OUw1WHa4/II= +github.com/firefart/nonamedreturns v1.0.8/go.mod h1:vxFNvm5AfP/8rgAKFzYmnqx0yp1HjrYsErZ9pHPTznA= +github.com/fluxcd/cli-utils v1.2.2 h1:adDOmwE+LSwTzmYUaoEFPblruOuaQEKAg1ZNTmPJObE= +github.com/fluxcd/cli-utils v1.2.2/go.mod h1:FsghNGY+3Sr70c0FOB7I5So0kzoYVdvQ8GTid3XXVWM= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= @@ -3456,8 +3458,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= -github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= -github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= +github.com/go-critic/go-critic v0.14.4 h1:dSX4C3pWSeuMVxvQh6yG8U0ReSf3YOmKi4nwX5q7n/8= +github.com/go-critic/go-critic v0.14.4/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= @@ -3530,67 +3532,67 @@ github.com/go-logr/zapr v0.1.0/go.mod h1:tabnROwaDl0UNxkVeFRbY8bwB37GwRv0P8lg6aA github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= -github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= +github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= +github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= -github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= +github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= github.com/go-openapi/runtime/server-middleware v0.32.3 h1:Y/6h9ix9NCoMG04XazRwX6eA3alh4+JZ6qXdar5yd24= github.com/go-openapi/runtime/server-middleware v0.32.3/go.mod h1:fYPep4GdTwg/XqZUjR40uIM/8C12Ba5M+MrGCiwpTHo= -github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= -github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= -github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= -github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= -github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= -github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= -github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= -github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= -github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= -github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= -github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= -github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= -github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= -github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= -github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= -github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= -github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= -github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= -github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= -github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= -github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= -github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= -github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= -github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= -github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= -github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= -github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= -github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= +github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg= +github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE= +github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE= +github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU= +github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ= +github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE= +github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc= +github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0= +github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE= +github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg= +github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU= +github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk= +github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI= +github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4= +github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs= +github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= +github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.8.0/go.mod h1:gfqhcNwXrsd3XYKte9a7vM3smvU/jB4ZRDrmWSxpfdc= @@ -3741,14 +3743,16 @@ github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= +github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 h1:fVLolA3dG6s0brGetsiDAtcnpMSwVa2LqXJEw/9RJG4= +github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6/go.mod h1:1xo+NFW5S+bEf2DKXhaxuzvcDN5AR6o/KMwvqC5Mkpo= github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A= github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= -github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= -github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= -github.com/golangci/golangci-lint/v2 v2.12.2 h1:7+d1uY0bq1MU2UV3R5pW5Q7QWdcoq4naMRXM+gsJKrs= -github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8= +github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg= +github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA= +github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU= +github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w= github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg= @@ -3797,12 +3801,10 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= -github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84= +github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0= github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= -github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= -github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= github.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA= github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8= github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= @@ -3854,8 +3856,8 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/rpmpack v0.7.1 h1:YdWh1IpzOjBz60Wvdw0TU0A5NWP+JTVHA5poDqwMO2o= github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= @@ -3924,8 +3926,8 @@ github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3Vcf github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= -github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= -github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg= +github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM= github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= @@ -3941,8 +3943,8 @@ github.com/goreleaser/fileglob v1.4.0 h1:Y7zcUnzQjT1gbntacGAkIIfLv+OwojxTXBFxjSF github.com/goreleaser/fileglob v1.4.0/go.mod h1:1pbHx7hhmJIxNZvm6fi6WVrnP0tndq6p3ayWdLn1Yf8= github.com/goreleaser/go-shellwords v1.0.13 h1:ivvhC/RvUyud74c0urb1ZGYWPYGibY5QiFzcwHCege4= github.com/goreleaser/go-shellwords v1.0.13/go.mod h1:UtDFSSvW7wQL/4jmyzZbuP6HfI6R+oSm0v63cs61oDw= -github.com/goreleaser/goreleaser/v2 v2.17.1 h1:7nWdnNZSeiutF2PKAKmCEdEnLO+n0BWDoj4AqNiMX1E= -github.com/goreleaser/goreleaser/v2 v2.17.1/go.mod h1:0yFW5JOLYuNncutj9ebbj8Ok8NXcKg7t0bEN+Q0HuWs= +github.com/goreleaser/goreleaser/v2 v2.18.0 h1:wm1GhXmZeNt4fo1/Z6GFXWcf5gxOyM4SUXFjX7JmGMY= +github.com/goreleaser/goreleaser/v2 v2.18.0/go.mod h1:QE3tgJP5g42Vn7bUN1wyR2XJjQ5wCwbMdXhcOrD2Keo= github.com/goreleaser/nfpm/v2 v2.47.0 h1:0bioJAjWaMPntgDqynP4ze0Wt4zYqYSFJ5/BBy9XIGI= github.com/goreleaser/nfpm/v2 v2.47.0/go.mod h1:EhVWY2GwWB0Zf7FDDVqpDDCtvIzeqcUsAinpHSE8wUo= github.com/goreleaser/quill v0.0.0-20260630015114-8310f3e9a321 h1:/O7X5L3FuwXHT055Aar5b53A3pwUtL4z/1W8EqicJHQ= @@ -4129,8 +4131,8 @@ github.com/ipfs/go-log/v2 v2.9.2 h1:O/5BB0elpkRILvT24rCJ5976wWd7u0nJ436T3rdYdc4= github.com/ipfs/go-log/v2 v2.9.2/go.mod h1:RziRwwXWhndlk8L75RnEe0zeAYaq2heKtEMc3jqUov0= github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= -github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= -github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= +github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI= +github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ= @@ -4141,8 +4143,8 @@ github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jezek/xgb v1.0.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= -github.com/jgautheron/goconst v1.10.2 h1:0wFg/DbpYB0ChoP94qUM+SuQpqj5oCzPEqAil5dV8Pg= -github.com/jgautheron/goconst v1.10.2/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= +github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk= +github.com/jgautheron/goconst v1.11.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -4202,8 +4204,8 @@ github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQs github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -4242,8 +4244,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ktrysmt/go-bitbucket v0.10.0 h1:miXSfqFb+j0pPBB9h8kw9f/oeq045WOPI4hplr996+k= github.com/ktrysmt/go-bitbucket v0.10.0/go.mod h1:IUB8I+gC3UO00NjNTMS7STjsZYq+EAhPuHgynzRWxqY= -github.com/kube-object-storage/lib-bucket-provisioner v0.0.0-20221122204822-d1a8c34382f1 h1:dQEHhTfi+bSIOSViQrKY9PqJvZenD6tFz+3lPzux58o= -github.com/kube-object-storage/lib-bucket-provisioner v0.0.0-20221122204822-d1a8c34382f1/go.mod h1:my+EVjOJLeQ9lUR9uVkxRvNNkhO2saSGIgzV8GZT9HY= +github.com/kube-object-storage/lib-bucket-provisioner v0.0.0-20260420161730-5164e3746489 h1:Tnm8Vr9GmtuWNmhp+HZI1I8pMXuhyVomTlvgFT2TQpE= +github.com/kube-object-storage/lib-bucket-provisioner v0.0.0-20260420161730-5164e3746489/go.mod h1:my+EVjOJLeQ9lUR9uVkxRvNNkhO2saSGIgzV8GZT9HY= github.com/kubernetes-csi/external-snapshotter/client/v4 v4.0.0/go.mod h1:YBCo4DoEeDndqvAn6eeu0vWM7QdXmHEeI9cFWplmBys= github.com/kubernetes-csi/external-snapshotter/client/v8 v8.6.0 h1:FtGewu2k6HWw6evLGXY8JqUZ9eHpti1kd3e4amj+ilA= github.com/kubernetes-csi/external-snapshotter/client/v8 v8.6.0/go.mod h1:Vxl89NySJ45J+ah3NTMan/KJXW+NpcGHE2Tw0GSw53k= @@ -4257,8 +4259,6 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= -github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= -github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= github.com/ldez/gomoddirectives v0.9.0 h1:2YV/EX7nVlWL4jySusYTzBKHuE3D2fgcRsQuMa3yIoo= @@ -4292,8 +4292,8 @@ github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffkt github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lithammer/shortuuid v3.0.0+incompatible h1:NcD0xWW/MZYXEHa6ITy6kaXN5nwm/V115vj2YXfhS0w= github.com/lithammer/shortuuid v3.0.0+incompatible/go.mod h1:FR74pbAuElzOUuenUHTK2Tciko1/vKuIKS9dSkDrA4w= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= @@ -4388,8 +4388,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= -github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= @@ -4397,8 +4397,8 @@ github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSD github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/modelcontextprotocol/registry v1.8.0 h1:x/seX0ji4iqRUpSovmkBcGbxfRiZZ7dgPwBcpCJrSTM= -github.com/modelcontextprotocol/registry v1.8.0/go.mod h1:G6AUpTpZSekQvcLl5griUjijEE8vedARE/TyCaHEFdo= +github.com/modelcontextprotocol/registry v1.8.1 h1:baHVpbY9xc/lyCi/Wemcy7idVBNKROJZbSgzosCEuEo= +github.com/modelcontextprotocol/registry v1.8.1/go.mod h1:W26bRO/fiGMqCfDFNFz8DWZbFSpGj87JsqwrTbvLIWU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -4460,8 +4460,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= -github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= -github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= +github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8= +github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= @@ -4485,8 +4485,8 @@ github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkA github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc= github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= -github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= -github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg= +github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= @@ -4499,8 +4499,8 @@ github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= -github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= -github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -4548,8 +4548,8 @@ github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= @@ -4709,10 +4709,10 @@ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncj github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= -github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= -github.com/rook/rook/pkg/apis v0.0.0-20260811210519-a4c28bc84816 h1:nyGynTLB5dkhWQ0bOy1TZZeseQshfeScH2kdootKsWA= -github.com/rook/rook/pkg/apis v0.0.0-20260811210519-a4c28bc84816/go.mod h1:79nbfxfJ+jQZKPvj2ZPvULSP5g+hEpXA0sj63m42GBA= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rook/rook/pkg/apis v0.0.0-20260915091545-fb2d518f653f h1:Qanv8Sx7u0ffw1Bpo4db9C662W0cgF1wcYnK4rPRu1Q= +github.com/rook/rook/pkg/apis v0.0.0-20260915091545-fb2d518f653f/go.mod h1:bXat1y3sXuW1IuIWfRrCL5HQqEPgVfUtzIVtEDpxcEo= github.com/rook/secrets v0.0.0-20240315053144-3195f6906937 h1:1TpdIqF9mtQfhNfwOpXdpJTMhx66PonVCCvYcGWvu/I= github.com/rook/secrets v0.0.0-20240315053144-3195f6906937/go.mod h1:jOxzr6jXuSz9UztMhEpcBi1/vPygUA4z9kFuFj+6zd8= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= @@ -4743,8 +4743,8 @@ github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= @@ -4760,8 +4760,8 @@ github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd7 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= -github.com/securego/gosec/v2 v2.27.1 h1:bg4lZnpCCpC8e5l0K+ADF5gG91jmT2LQgOcOflwBfJI= -github.com/securego/gosec/v2 v2.27.1/go.mod h1:lbgwsogcxq9aoN62Bk/vcdWwemFjlT5NPF/D/dH4+Ho= +github.com/securego/gosec/v2 v2.28.0 h1:ZsSdiDb0AtTpLFVol5z91gbMei9ZiLEPG/pZjZujp7c= +github.com/securego/gosec/v2 v2.28.0/go.mod h1:lb4/9AHe+lJy/kjWmWRWWsEipvbwGKuxf+tY1Pmjdnk= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= @@ -4799,14 +4799,14 @@ github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q= +github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= -github.com/slack-go/slack v0.27.0 h1:VWOpUzOK6UAPCCQlFxl79jhv8a/b+GOSJMnWziDJ8B8= -github.com/slack-go/slack v0.27.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4= +github.com/slack-go/slack v0.29.0 h1:ohhMNgp9DmPKiLhH/pNZV4NxhOXKgNy0SH8FzVHNerI= +github.com/slack-go/slack v0.29.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= @@ -4880,8 +4880,9 @@ github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg= @@ -4949,10 +4950,10 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4= github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q= -github.com/uudashr/iface v1.5.0 h1:PgdMt4uAettGG8K/Kbamc4B9FABgUgnS3TLbl6fnjEk= -github.com/uudashr/iface v1.5.0/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= -github.com/vektra/mockery/v3 v3.7.2 h1:x4r2DwXSdOZdrs89+IKX4VS1dfJySc/YbZsrW80zMBY= -github.com/vektra/mockery/v3 v3.7.2/go.mod h1:fbChccNiUvQaUVaCHS6/7OL5/D65KljJVk31LuPPUjY= +github.com/uudashr/iface v1.5.1 h1:BS3yrgaT55s3dAtJoxuTnsZNbsejq/mVuzp49YDyfBA= +github.com/uudashr/iface v1.5.1/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw= +github.com/vektra/mockery/v3 v3.7.4 h1:t2qElHpzlKKJA63nrEmnkdwy+OaBV1t6uAf/YpHu4EU= +github.com/vektra/mockery/v3 v3.7.4/go.mod h1:K+L72OoFVizA9eWtO4L+kU65oylgreiJEJ57Gu8g9Ew= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= @@ -5037,8 +5038,8 @@ go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4= go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I= go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50= go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA= -go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= -go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= +go.augendre.info/fatcontext v0.10.0 h1:HhFopmivh8U1+AU7f0kuwUeg2eiIns7YsGQOMHwSJ90= +go.augendre.info/fatcontext v0.10.0/go.mod h1:pqpGvA9GlrXy+aXkp8L2dKz12Zp4g2FhzcAtwToU+2w= go.digitalxero.dev/go-msix v0.3.1 h1:V5E8PuFkA3Fr3VFYX6pTUutriogYC9sgxIWhzf9sSKw= go.digitalxero.dev/go-msix v0.3.1/go.mod h1:QbUpFs0AUd1zk7e9fy17suiqEAF90TR3jZY+LCI2K+c= go.einride.tech/aip v0.66.0/go.mod h1:qAhMsfT7plxBX+Oy7Huol6YUvZ0ZzdUz26yZsQwfl1M= @@ -5362,8 +5363,14 @@ golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvm golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -5403,8 +5410,8 @@ golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRY golang.org/x/exp/shiny v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 h1:GTD/WuaexTazIG/SxLOz4rEKZPDVilmVVC2nz4xhwfE= -golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= +golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f h1:+lI8cDJ4uceLipg2f1ODay7fEuLkk0BIHXd6PB8icxo= +golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -5486,8 +5493,13 @@ golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= -golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -5600,8 +5612,16 @@ golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -5655,8 +5675,8 @@ golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -5691,8 +5711,11 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -5837,8 +5860,15 @@ golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= @@ -5848,6 +5878,11 @@ golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJ golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -5889,8 +5924,14 @@ golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -5930,8 +5971,15 @@ golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -6061,8 +6109,13 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= @@ -6208,8 +6261,8 @@ google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1Gk google.golang.org/api v0.222.0/go.mod h1:efZia3nXpWELrwMlN5vyQrD4GmJN1Vw0x68Et3r+a9c= google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ= google.golang.org/api v0.228.0/go.mod h1:wNvRS1Pbe8r4+IfBIniV8fwCpGwTrYa+kMUDiC5z5a4= -google.golang.org/api v0.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w= -google.golang.org/api v0.293.0/go.mod h1:6n5tjEB1gzwniZTepZ0g5u+wM7Bof5GeULCx/zh8ZE0= +google.golang.org/api v0.298.0 h1:YW18RkHBMZBA1ergX0m4biagzgbiPTb2uTsRsDPWNRY= +google.golang.org/api v0.298.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -6638,8 +6691,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea h1:kVhQEPTpKQahD5+JSBTfBB19wcgQTTjAIn45MBqnyHk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= @@ -6725,8 +6778,8 @@ google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y= google.golang.org/grpc/examples v0.0.0-20201112215255-90f1b3ee835b/go.mod h1:IBqQ7wSUJ2Ep09a8rMWFsg4fmI2r38zwsq8a0GgxXpM= @@ -6823,8 +6876,8 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -helm.sh/helm/v4 v4.2.3 h1:JEejtPE04+SvyRomOfgRXVxyJ/lude7eShio30oQr0Y= -helm.sh/helm/v4 v4.2.3/go.mod h1:azI2XpxowOGXAgzeXcqyfskUmIfILqIcJxiFw1M6PuM= +helm.sh/helm/v4 v4.3.0 h1:wLRTNXzy96ro7waurWMsymlUPaCQOj5nokpJZJNen/w= +helm.sh/helm/v4 v4.3.0/go.mod h1:p6SMo6BMyg+4H52fJXvRNg1To0a0KGfiPxjVEcHHSk0= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -6834,27 +6887,27 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= -honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= -honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= -k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= -k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= -k8s.io/apiextensions-apiserver v0.36.3 h1:dPmOAPhwTtqb1bTxbFPsy18KHPhktQeO3WUPXunZIB0= -k8s.io/apiextensions-apiserver v0.36.3/go.mod h1:KTXFqgXiuw2pRoL+Wpmttqc+up9Xt/GohadPWeLLOa4= -k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= -k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= -k8s.io/apiserver v0.36.3 h1:MGSg2SkdfuytiDEcRylT5mQFmmSsbx90XFUO67Y4bsQ= -k8s.io/apiserver v0.36.3/go.mod h1:fVH7zv9EUNUA7Fl7LtDKh8aB9W7u1VQPSGtWV5SjUxg= -k8s.io/cli-runtime v0.36.3 h1:g+eJ+M1sYpnNYp/q5fzaw2KejIL0Q7DH+xFl6YVoL4U= -k8s.io/cli-runtime v0.36.3/go.mod h1:hZpAqK8nSFXvvLaVCbzUPVp8e9TRLSTCfpNzMt7s3tE= -k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= -k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= -k8s.io/code-generator v0.36.3/go.mod h1:Unn13Mp8X+H803jgZi4f4ExxK11aj0llXcSsl++UTkE= -k8s.io/component-base v0.36.3 h1:vc/UFvPCkW0irPz84LAodAL1j3f4xktPM6dDJIEheAY= -k8s.io/component-base v0.36.3/go.mod h1:hZbNFG+gCMl9EbykDGEu73feKP9/Cq6JsV4pTo9GTO8= -k8s.io/component-helpers v0.36.3 h1:hya22S0Mto0SlHaiD4kMIi817f/tK7uTMsShxrDKQaY= -k8s.io/component-helpers v0.36.3/go.mod h1:QjREK1lOFXR+jxTqzrtHgOtzUc2s9sm8zuFSiK+TW+c= -k8s.io/controller-manager v0.36.3 h1:GUP9E6+1EUEzxZgceOw2I+jyuahrTluadj6ZoZ/1Sbw= -k8s.io/controller-manager v0.36.3/go.mod h1:zrjaNqXRz4vfK5TdOQZEfOiTofcyIKrt/Y+jKdwqLa4= +honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= +k8s.io/api v0.36.4 h1:RxrvqCL6vgH5/+UnTeu1IIFqYmGfy0hnyrod1rn35Oo= +k8s.io/api v0.36.4/go.mod h1:S2B3orCFBDhrgyWbLeuKcT2QdHIpQesBkCYSlWtwUOw= +k8s.io/apiextensions-apiserver v0.36.4 h1:SfvCVt+4CqKWvzuVytYDT5g9hyb9MztoiYELIkPVrFc= +k8s.io/apiextensions-apiserver v0.36.4/go.mod h1:JT9V2Ju7ys1FY4zbSpmX9XOvKB3/BwsODc4hFQEa+Xo= +k8s.io/apimachinery v0.36.4 h1:PT2UzkupGuAx/+xT5XjiMJ1WGpY3fn9/hdAvjweRet4= +k8s.io/apimachinery v0.36.4/go.mod h1:p2I2dipt7JHG+quVwQ1d02d28O4GdDi77RByQ13MTpk= +k8s.io/apiserver v0.36.4 h1:AtKjaf2eUiX5G6TfF2IOlhuUuvMsHh49Ivr1+4fZ2gA= +k8s.io/apiserver v0.36.4/go.mod h1:RyiGghXP67hb0Ll+7iLJ6GGv2JpEzCn7ljbiA+L3cJ0= +k8s.io/cli-runtime v0.36.4 h1:OHvManCwP1k9GiC5tXRFxHhzZIQQFCsrHlt7OspKo3w= +k8s.io/cli-runtime v0.36.4/go.mod h1:qQSj2FJgQos6GHpS/ge7wTdQMZm9XFWlesWgV6h7qZY= +k8s.io/client-go v0.36.4 h1:MDvfDNvMSt0Br94SK8neviVlwL9qifw9B26hJCpD1K0= +k8s.io/client-go v0.36.4/go.mod h1:pNK4WKELbwlEDvtbE8l22lEZL5THYF61H5EealokZmA= +k8s.io/code-generator v0.36.4/go.mod h1:JmRrlYwTOn2glEuGOfNnlXTGbz3FfMg7t7Dq/8lHcc4= +k8s.io/component-base v0.36.4 h1:tz75yC2xgq3kd7vPdBtR8do5iMx0OHf6Zd1kuaxDB84= +k8s.io/component-base v0.36.4/go.mod h1:DCwb306U8ou89NNAp45Csuy8ok+1rp1ELDVPhzN5AWc= +k8s.io/component-helpers v0.36.4 h1:B5+SnptAlwTVJO0MW1JLsZVp4KevlADRg0CX5YFzjbU= +k8s.io/component-helpers v0.36.4/go.mod h1:RECUDkRdVuxcNOAl9/EK7lmy1TIjoWkBHs6f0uOyoCs= +k8s.io/controller-manager v0.36.4 h1:YG03tVDUY+BGLRTsNKY0Vms3lsCdq/314DW3IGZfZig= +k8s.io/controller-manager v0.36.4/go.mod h1:63s+JKgdrkqQl3AI2PovT22YMaiXC1i80/LRGLlFVjo= k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= @@ -6867,27 +6920,28 @@ k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kms v0.36.3/go.mod h1:g91diTD9h0oJCCHkTb00krlF+Qm5HTnkWLi9Q/TpRoc= -k8s.io/kube-aggregator v0.36.3 h1:eypRCZKyGx3u9TLdnLva47l6R/67Zs9h9FQI+uMruaY= -k8s.io/kube-aggregator v0.36.3/go.mod h1:WLfUZLoYlcuy+LnfBOv9eV9bVvNf+x8dYt0mfVrq/6Y= +k8s.io/kms v0.36.4/go.mod h1:7GDHEJmWmfAJR4eBr4YzM272dPBmoVTgTkoCUOumzq4= +k8s.io/kube-aggregator v0.36.4 h1:B593UGiOA2ivyuWOSvgWly5lXkTPLcYpeZwkq4Y+ieA= +k8s.io/kube-aggregator v0.36.4/go.mod h1:05q7hjy8iKStLM1e+BOQ8mMVPnIfAqfpkhZDPGxS0xY= k8s.io/kube-openapi v0.0.0-20180731170545-e3762e86a74c/go.mod h1:BXM9ceUBTj2QnfH2MK1odQs778ajze1RxcmP6S8RVVc= k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25 h1:mPMaPMpBij2V1Wv/fR+HW124vVGXXvOSS9ver/9yjWs= -k8s.io/kube-openapi v0.0.0-20260603220949-865597e52e25/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= -k8s.io/kubectl v0.36.3 h1:TesKp+XYQEjPYoFvuobcVnuvira2+/xAVlq//+kksaI= -k8s.io/kubectl v0.36.3/go.mod h1:W+NEb1CzBGmoaI1Nrpn2ETo9omNBl0AsyxnnMT40N6E= -k8s.io/kubelet v0.36.3 h1:dRzEnhHk35Opy6wjWR4YBcN5RI9lB2npUY37TghFuPU= -k8s.io/kubelet v0.36.3/go.mod h1:4USFGr21Ioka+b964Beq0NvV5b5aca3RWJ1/kfq+RLw= -k8s.io/kubernetes v1.36.3 h1:qDQdoMiluAE2Eab6Fa52YV+WjiGz9mZFFoagEA6cI+o= -k8s.io/kubernetes v1.36.3/go.mod h1:6oChkQeI7Yf6lV9lFpSdRzODdbY/ECp/4zUeBk8ONaw= -k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w= -k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/kubectl v0.36.4 h1:xZd9g1bFBd7hpb1oKjK8lT9jRL18dtgr4DAPQG1Oksk= +k8s.io/kubectl v0.36.4/go.mod h1:STWlr78cdEa1hHpr55wpcboaqchvfDueKRNDa1zOd1w= +k8s.io/kubelet v0.36.4 h1:mlmXnkrq3H02r/r0H/8M2jdPY7f4I4u4cA0tHnsPzY0= +k8s.io/kubelet v0.36.4/go.mod h1:jcOhk4E8cdUBn7WswW67WH9waQTe37G057ttnYdcaKY= +k8s.io/kubernetes v1.36.4 h1:08GT0ZOMtyCcRnslyvnpWurf1wm20KHbC/aRS+0LxSo= +k8s.io/kubernetes v1.36.4/go.mod h1:ZyLkHB4+fxSZ4LqpShPGJhikj4ngJaYAFlaZKs7goP4= +k8s.io/streaming v0.37.0 h1:iPBUZLZiKt5bV+lxJurASMOV07VuBhNpiwJt2//AWrM= +k8s.io/streaming v0.37.0/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU= k8s.io/utils v0.0.0-20190506122338-8fab8cb257d5/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20221128185143-99ec85e7a448/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= @@ -6952,10 +7006,10 @@ modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= -mvdan.cc/gofumpt v0.10.0 h1:yGGpRS2pBN2OQIi7b21IXknJna7faPkFaVfHLrN6Euo= -mvdan.cc/gofumpt v0.10.0/go.mod h1:sU2ElXHzOEmvoPqfutYG7uunlueR4K2T1JFml40SzP4= -mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= -mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= +mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc= +mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo= +mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew= +mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= @@ -6984,8 +7038,8 @@ sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxO sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= sigs.k8s.io/structured-merge-diff/v6 v6.2.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= -sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/testing_frameworks v0.1.1/go.mod h1:VVBKrHmJ6Ekkfz284YKhQePcdycOzNH9qL6ht1zEr/U= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/internal/bootstrap/datacenter/datacenter.go b/internal/bootstrap/datacenter/datacenter.go new file mode 100644 index 000000000..5b9a531ee --- /dev/null +++ b/internal/bootstrap/datacenter/datacenter.go @@ -0,0 +1,113 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package datacenter models one Codesphere data center of a bootstrapped installation: the nodes +// it runs on, the addresses it is reached through, and the paths of the config and vault that +// describe it. A multi-data-center installation has one of these per data center. +// +// The model deliberately carries no reference to the infrastructure a data center runs on, so the +// bootstrap flows can share it and derive it from their own environment. +package datacenter + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/node" +) + +// PrimaryID is the ID of the first data center. It stays 1 in both single- and multi-DC mode, so +// single-DC installations keep their existing dataCenter.id. +const PrimaryID = 1 + +// DataCenter holds the state of one Codesphere data center. Everything that a bootstrap shares +// between its data centers (the cloud project, the network, the jumpbox, the PostgreSQL server +// and the container registry) stays with the bootstrap; everything that must differ between them +// lives here. +type DataCenter struct { + ID int `json:"id"` + Name string `json:"name"` + // Suffix is appended to data-center-scoped resource names. It is empty for the primary + // data center, so single-DC bootstraps keep the resource names they have always used. + Suffix string `json:"suffix"` + + ControlPlaneNodes []*node.Node `json:"control_plane_nodes"` + CephNodes []*node.Node `json:"ceph_nodes"` + + GatewayIP string `json:"gateway_ip"` + PublicGatewayIP string `json:"public_gateway_ip"` + SSHProxyIP string `json:"ssh_proxy_ip"` + + // Local paths of the generated config and vault. + InstallConfigPath string `json:"-"` + SecretsFilePath string `json:"-"` + // Paths on the shared jumpbox. + RemoteConfigPath string `json:"remote_config_path"` + SecretsDir string `json:"secrets_dir"` + + WorkspaceHostingBaseDomain string `json:"workspace_hosting_base_domain"` + SSHBaseDomain string `json:"ssh_base_domain"` + + // ExternalPostgres marks a data center that uses the primary data center's PostgreSQL + // server instead of installing its own. + ExternalPostgres bool `json:"external_postgres"` + + InstallConfig *files.RootConfig `json:"-"` + ExistingConfigUsed bool `json:"-"` + // ConfigManager owns this data center's config and vault. It is not serialised, so a data + // center restored from an infra file is given a fresh one. + ConfigManager installer.InstallConfigManager `json:"-"` +} + +// IsPrimary reports whether this is the first data center of the installation. The primary data +// center owns the shared PostgreSQL server and the platform gateway that codesphere.domain +// resolves to. +func (dc *DataCenter) IsPrimary() bool { + return dc.Suffix == "" +} + +// RemoteVaultPath returns the path of this data center's vault on the jumpbox. +func (dc *DataCenter) RemoteVaultPath() string { + return filepath.Join(dc.SecretsDir, "prod.vault.yaml") +} + +// RemoteAgeKeyPath returns the path of this data center's age identity on the jumpbox. +func (dc *DataCenter) RemoteAgeKeyPath() string { + return filepath.Join(dc.SecretsDir, "age_key.txt") +} + +// K0sConfigScriptPath returns the local filename of this data center's k0s configuration script. +func (dc *DataCenter) K0sConfigScriptPath() string { + return fmt.Sprintf("configure-k0s%s.sh", dc.Suffix) +} + +// StepName qualifies a bootstrap step name with the data center it applies to. Single-DC +// bootstraps keep their unqualified step names. +func (dc *DataCenter) StepName(name string) string { + if dc.Suffix == "" { + return name + } + + return fmt.Sprintf("%s (dc %d)", name, dc.ID) +} + +// SuffixedPath inserts a data-center suffix before the file extension, turning config.yaml into +// config-dc2.yaml and prod.vault.yaml into prod-dc2.vault.yaml. The primary data center's empty +// suffix leaves the path untouched. +func SuffixedPath(path, suffix string) string { + if suffix == "" { + return path + } + + dir, file := filepath.Split(path) + + base, ext := file, "" + if idx := strings.Index(file, "."); idx > 0 { + base, ext = file[:idx], file[idx:] + } + + return filepath.Join(dir, base+suffix+ext) +} diff --git a/internal/bootstrap/datacenter/datacenter_suite_test.go b/internal/bootstrap/datacenter/datacenter_suite_test.go new file mode 100644 index 000000000..a5e64966b --- /dev/null +++ b/internal/bootstrap/datacenter/datacenter_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package datacenter_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDataCenter(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "DataCenter Suite") +} diff --git a/internal/bootstrap/datacenter/datacenter_test.go b/internal/bootstrap/datacenter/datacenter_test.go new file mode 100644 index 000000000..edaeaec17 --- /dev/null +++ b/internal/bootstrap/datacenter/datacenter_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package datacenter_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/bootstrap/datacenter" +) + +// The methods of DataCenter are asserted against the real layout in the gcp package's +// BuildDataCenters test, so only the path derivation is covered here. +var _ = Describe("SuffixedPath", func() { + DescribeTable("inserts the suffix before the extension", + func(path, suffix, expected string) { + Expect(datacenter.SuffixedPath(path, suffix)).To(Equal(expected)) + }, + Entry("primary keeps its path", "config.yaml", "", "config.yaml"), + Entry("single extension", "config.yaml", "-dc2", "config-dc2.yaml"), + // prod.vault.yaml must become prod-dc2.vault.yaml, not prod.vault-dc2.yaml, so the + // suffix goes before the first dot rather than the last. + Entry("compound extension", "prod.vault.yaml", "-dc2", "prod-dc2.vault.yaml"), + Entry("no extension", "config", "-dc2", "config-dc2"), + Entry("absolute path", "/etc/codesphere/config.yaml", "-dc2", "/etc/codesphere/config-dc2.yaml"), + ) +}) diff --git a/internal/bootstrap/gcp/cleanup.go b/internal/bootstrap/gcp/cleanup.go index f30e59a73..c29a464bc 100644 --- a/internal/bootstrap/gcp/cleanup.go +++ b/internal/bootstrap/gcp/cleanup.go @@ -56,20 +56,25 @@ func NewCleanupExecutor(opts *CleanupOpts, deps *CleanupDeps) (*CleanupExecutor, if err := exec.loadInfraFileIfNeeded(); err != nil { return nil, err } + if err := exec.resolveProjectID(); err != nil { return nil, err } + exec.resolveDNSSettings() + return exec, nil } // loadInfraFileIfNeeded loads the infra file when the project ID or DNS info is missing. func (e *CleanupExecutor) loadInfraFileIfNeeded() error { missingDNSProjectID := e.Opts.DNSProjectID == "" + missingDNSInfo := missingDNSProjectID if !e.Opts.SkipDNSCleanup { missingDNSInfo = missingDNSProjectID || e.Opts.BaseDomain == "" || e.Opts.DNSZoneName == "" } + if e.ProjectID != "" && !missingDNSInfo { return nil } @@ -79,13 +84,16 @@ func (e *CleanupExecutor) loadInfraFileIfNeeded() error { if e.ProjectID == "" { return fmt.Errorf("failed to load infra file: %w", err) } + log.Printf("Warning: %v", err) + return nil } if infraEnv.ProjectID != "" { e.InfraEnv = infraEnv e.InfraFileLoaded = true + return nil } @@ -104,6 +112,7 @@ func (e *CleanupExecutor) resolveProjectID() error { e.InfraEnv = CodesphereEnvironment{} e.InfraFileLoaded = false } + return nil } @@ -113,6 +122,7 @@ func (e *CleanupExecutor) resolveProjectID() error { e.ProjectID = e.InfraEnv.ProjectID log.Printf("Using project ID from infra file: %s", e.ProjectID) + return nil } @@ -122,14 +132,17 @@ func (e *CleanupExecutor) resolveDNSSettings() { if e.BaseDomain == "" { e.BaseDomain = e.InfraEnv.BaseDomain } + e.DNSZoneName = e.Opts.DNSZoneName if e.DNSZoneName == "" { e.DNSZoneName = e.InfraEnv.DNSZoneName } + e.DNSProjectID = e.Opts.DNSProjectID if e.DNSProjectID == "" { e.DNSProjectID = e.InfraEnv.DNSProjectID } + if e.DNSProjectID == "" { e.DNSProjectID = e.ProjectID } @@ -147,6 +160,7 @@ func (e *CleanupExecutor) VerifyAndConfirm() error { if err != nil { return fmt.Errorf("failed to verify project: %w", err) } + if !isOMSManaged { return fmt.Errorf("project %s was not bootstrapped by OMS (missing 'oms-managed' label). Use --force to override this check", e.ProjectID) } @@ -160,13 +174,16 @@ func (e *CleanupExecutor) confirmDeletion() error { log.Println("Type the project ID to confirm deletion: ") reader := bufio.NewReader(e.Deps.ConfirmReader) + confirmation, err := reader.ReadString('\n') if err != nil { return fmt.Errorf("failed to read confirmation: %w", err) } + if strings.TrimSpace(confirmation) != e.ProjectID { return fmt.Errorf("confirmation did not match project ID, aborting cleanup") } + return nil } @@ -176,10 +193,12 @@ func (e *CleanupExecutor) CleanupDNSRecords() error { if e.Opts.SkipDNSCleanup { return nil } + if e.BaseDomain == "" || e.DNSZoneName == "" { log.Printf("Skipping DNS cleanup: missing base domain or DNS zone name (provide --base-domain/--dns-zone-name or use --skip-dns-cleanup)") return nil } + return e.Deps.GCPClient.DeleteDNSRecordSets(e.DNSProjectID, e.DNSZoneName, e.BaseDomain) } @@ -189,6 +208,7 @@ func (e *CleanupExecutor) RemoveDNSIAMBinding() error { if e.DNSProjectID == "" || e.DNSProjectID == e.ProjectID { return nil } + return e.Deps.GCPClient.RemoveIAMRoleBinding(e.DNSProjectID, "cloud-controller", e.ProjectID, []string{"roles/dns.admin"}) } @@ -202,9 +222,11 @@ func (e *CleanupExecutor) RemoveLocalInfraFile() { if !e.InfraFileLoaded || e.InfraEnv.ProjectID != e.ProjectID { return } + if err := e.Deps.FileIO.Remove(e.Deps.InfraFilePath); err != nil { log.Printf("Warning: failed to remove local infra file: %v", err) return } + log.Printf("Removed local infra file: %s", e.Deps.InfraFilePath) } diff --git a/internal/bootstrap/gcp/datacenter.go b/internal/bootstrap/gcp/datacenter.go new file mode 100644 index 000000000..75f23fda7 --- /dev/null +++ b/internal/bootstrap/gcp/datacenter.go @@ -0,0 +1,177 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/bootstrap/datacenter" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/vault" +) + +// BuildDataCenters derives the data center layout from the bootstrap environment: a single +// entry in single-DC mode, and two entries in multi-DC mode where the second one shares the +// first one's PostgreSQL server. +func BuildDataCenters(env *CodesphereEnvironment) []*datacenter.DataCenter { + if !env.MultiDC { + // A single data center keeps honouring --datacenter-id. In multi-DC mode the IDs are + // derived instead, because they drive the per-data-center domains; validateMultiDC + // rejects the combination. + id := env.DatacenterID + if id == 0 { + id = datacenter.PrimaryID + } + + return []*datacenter.DataCenter{newDataCenter(env, id, "")} + } + + return []*datacenter.DataCenter{ + newDataCenter(env, datacenter.PrimaryID, ""), + newDataCenter(env, datacenter.PrimaryID+1, "-dc2"), + } +} + +// ensureDataCenters makes sure the environment has a usable data center layout. It derives the +// layout on first use and gives every data center an install config manager, so any entry point +// works whether or not Bootstrap ran first. +func (b *GCPBootstrapper) ensureDataCenters() error { + if len(b.Env.DataCenters) > 0 { + return b.ensureConfigManagers() + } + + b.Env.DataCenters = BuildDataCenters(b.Env) + b.adoptLegacyEnvFields() + + return b.ensureConfigManagers() +} + +// ensureConfigManagers gives every data center an install config manager. The primary one reuses +// the bootstrapper's, so a single-DC bootstrap behaves exactly as it did before multi-DC support. +// Data centers restored from an infra file arrive without a manager, since it is not serialised. +func (b *GCPBootstrapper) ensureConfigManagers() error { + for i, dc := range b.Env.DataCenters { + if dc.ConfigManager != nil { + continue + } + + if i == 0 && b.icg != nil { + dc.ConfigManager = b.icg + continue + } + + manager, err := installer.NewInstallConfigManager(string(vault.TypePlain), "") + if err != nil { + return fmt.Errorf("failed to initialize config manager for data center %d: %w", dc.ID, err) + } + + dc.ConfigManager = manager + } + + return nil +} + +// adoptLegacyEnvFields moves state that a caller supplied through the deprecated top-level +// environment fields into the primary data center. Infra files written before multi-DC support +// carry the primary data center's nodes and IPs there. +func (b *GCPBootstrapper) adoptLegacyEnvFields() { + primary := b.Env.DataCenters[0] + if len(primary.ControlPlaneNodes) == 0 { + primary.ControlPlaneNodes = b.Env.ControlPlaneNodes + } + + if len(primary.CephNodes) == 0 { + primary.CephNodes = b.Env.CephNodes + } + + if primary.GatewayIP == "" { + primary.GatewayIP = b.Env.GatewayIP + } + + if primary.PublicGatewayIP == "" { + primary.PublicGatewayIP = b.Env.PublicGatewayIP + } + + if primary.SSHProxyIP == "" { + primary.SSHProxyIP = b.Env.SshProxyIP + } + + if primary.InstallConfig == nil { + primary.InstallConfig = b.Env.InstallConfig + } + // A caller that supplied a config through the environment also tells us whether it is an + // existing one, which decides between generating and regenerating secrets. + if b.Env.ExistingConfigUsed { + primary.ExistingConfigUsed = true + } +} + +// mirrorPrimaryDataCenter projects the primary data center's state back onto the top-level +// environment fields it lived in before multi-DC support. The steps that still read those fields +// keep working while they are migrated one by one, and the infra file keeps the shape an earlier +// OMS wrote. The projection is one-way and never read back into a DataCenter. +func (b *GCPBootstrapper) mirrorPrimaryDataCenter() { + if len(b.Env.DataCenters) == 0 { + return + } + + primary := b.primaryDC() + b.Env.ControlPlaneNodes = primary.ControlPlaneNodes + b.Env.CephNodes = primary.CephNodes + b.Env.GatewayIP = primary.GatewayIP + b.Env.PublicGatewayIP = primary.PublicGatewayIP + b.Env.SshProxyIP = primary.SSHProxyIP + b.Env.InstallConfig = primary.InstallConfig + b.Env.ExistingConfigUsed = primary.ExistingConfigUsed +} + +// newDataCenter builds one data center, deriving its resource names, file paths and domains +// from the environment and the data-center suffix. +func newDataCenter(env *CodesphereEnvironment, id int, suffix string) *datacenter.DataCenter { + name := env.DatacenterName + if name == "" { + name = "dev" + } + + if suffix != "" { + // The k0s cluster is named codesphere-, so the names must differ. + name += suffix + } + + dc := &datacenter.DataCenter{ + ID: id, + Name: name, + Suffix: suffix, + InstallConfigPath: datacenter.SuffixedPath(env.InstallConfigPath, suffix), + SecretsFilePath: datacenter.SuffixedPath(env.SecretsFilePath, suffix), + RemoteConfigPath: datacenter.SuffixedPath(remoteInstallConfigPath, suffix), + SecretsDir: env.SecretsDir + suffix, + WorkspaceHostingBaseDomain: workspaceHostingBaseDomain(env, id), + SSHBaseDomain: sshBaseDomain(env, id), + ExternalPostgres: suffix != "", + } + + return dc +} + +// workspaceHostingBaseDomain returns the domain workspaces of the given data center are served +// from. Single-DC installations keep ws.; multi-DC installations prefix it with the +// data center ID so each data center's public gateway gets its own name. +func workspaceHostingBaseDomain(env *CodesphereEnvironment, id int) string { + if !env.MultiDC { + return "ws." + env.BaseDomain + } + + return fmt.Sprintf("%d.ws.%s", id, env.BaseDomain) +} + +// sshBaseDomain returns the domain the workspace SSH proxy of the given data center is served +// from, following the same scheme as workspaceHostingBaseDomain. +func sshBaseDomain(env *CodesphereEnvironment, id int) string { + if !env.MultiDC { + return "ssh.cs." + env.BaseDomain + } + + return fmt.Sprintf("%d.ssh.cs.%s", id, env.BaseDomain) +} diff --git a/internal/bootstrap/gcp/datacenter_test.go b/internal/bootstrap/gcp/datacenter_test.go new file mode 100644 index 000000000..fd44e48f9 --- /dev/null +++ b/internal/bootstrap/gcp/datacenter_test.go @@ -0,0 +1,101 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/bootstrap/datacenter" + "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" +) + +var _ = Describe("BuildDataCenters", func() { + newEnv := func(multiDC bool) *gcp.CodesphereEnvironment { + return &gcp.CodesphereEnvironment{ + MultiDC: multiDC, + BaseDomain: "example.com", + DatacenterName: "dev", + SecretsDir: "/etc/codesphere/secrets", + InstallConfigPath: "config.yaml", + SecretsFilePath: "prod.vault.yaml", + } + } + + Context("single data center", func() { + It("keeps the paths, secrets dir and domains a single-DC bootstrap has always used", func() { + dcs := gcp.BuildDataCenters(newEnv(false)) + + Expect(dcs).To(HaveLen(1)) + dc := dcs[0] + Expect(dc.IsPrimary()).To(BeTrue()) + Expect(dc.ID).To(Equal(1)) + Expect(dc.Name).To(Equal("dev")) + Expect(dc.Suffix).To(BeEmpty()) + Expect(dc.InstallConfigPath).To(Equal("config.yaml")) + Expect(dc.SecretsFilePath).To(Equal("prod.vault.yaml")) + Expect(dc.RemoteConfigPath).To(Equal("/etc/codesphere/config.yaml")) + Expect(dc.SecretsDir).To(Equal("/etc/codesphere/secrets")) + Expect(dc.RemoteVaultPath()).To(Equal("/etc/codesphere/secrets/prod.vault.yaml")) + Expect(dc.RemoteAgeKeyPath()).To(Equal("/etc/codesphere/secrets/age_key.txt")) + Expect(dc.K0sConfigScriptPath()).To(Equal("configure-k0s.sh")) + Expect(dc.WorkspaceHostingBaseDomain).To(Equal("ws.example.com")) + Expect(dc.SSHBaseDomain).To(Equal("ssh.cs.example.com")) + Expect(dc.ExternalPostgres).To(BeFalse()) + Expect(dc.StepName("Encrypt vault")).To(Equal("Encrypt vault")) + }) + }) + + Context("multi data center", func() { + var dcs []*datacenter.DataCenter + + BeforeEach(func() { + dcs = gcp.BuildDataCenters(newEnv(true)) + }) + + It("builds two data centers with the second sharing the first's postgres", func() { + Expect(dcs).To(HaveLen(2)) + Expect(dcs[0].ExternalPostgres).To(BeFalse()) + Expect(dcs[1].ExternalPostgres).To(BeTrue()) + }) + + It("leaves the primary data center's resource names unsuffixed", func() { + Expect(dcs[0].Suffix).To(BeEmpty()) + Expect(dcs[0].InstallConfigPath).To(Equal("config.yaml")) + Expect(dcs[0].SecretsDir).To(Equal("/etc/codesphere/secrets")) + }) + + It("gives the secondary data center its own name, paths and secrets dir", func() { + dc := dcs[1] + Expect(dc.IsPrimary()).To(BeFalse()) + Expect(dc.ID).To(Equal(2)) + // The k0s cluster is named codesphere-, so the names must differ. + Expect(dc.Name).To(Equal("dev-dc2")) + Expect(dc.InstallConfigPath).To(Equal("config-dc2.yaml")) + Expect(dc.SecretsFilePath).To(Equal("prod-dc2.vault.yaml")) + Expect(dc.RemoteConfigPath).To(Equal("/etc/codesphere/config-dc2.yaml")) + // A separate secrets dir, so the installer cannot overwrite the primary's kubeconfig + // and ceph credentials through config.secrets.baseDir. + Expect(dc.SecretsDir).To(Equal("/etc/codesphere/secrets-dc2")) + Expect(dc.RemoteVaultPath()).To(Equal("/etc/codesphere/secrets-dc2/prod.vault.yaml")) + Expect(dc.RemoteAgeKeyPath()).To(Equal("/etc/codesphere/secrets-dc2/age_key.txt")) + Expect(dc.K0sConfigScriptPath()).To(Equal("configure-k0s-dc2.sh")) + Expect(dc.StepName("Encrypt vault")).To(Equal("Encrypt vault (dc 2)")) + }) + + It("scopes the workspace and ssh domains per data center", func() { + Expect(dcs[0].WorkspaceHostingBaseDomain).To(Equal("1.ws.example.com")) + Expect(dcs[0].SSHBaseDomain).To(Equal("1.ssh.cs.example.com")) + Expect(dcs[1].WorkspaceHostingBaseDomain).To(Equal("2.ws.example.com")) + Expect(dcs[1].SSHBaseDomain).To(Equal("2.ssh.cs.example.com")) + }) + }) + + It("falls back to the dev datacenter name", func() { + env := newEnv(false) + env.DatacenterName = "" + + Expect(gcp.BuildDataCenters(env)[0].Name).To(Equal("dev")) + }) +}) diff --git a/internal/bootstrap/gcp/errors.go b/internal/bootstrap/gcp/errors.go index 1c6f44e61..8e38f05ae 100644 --- a/internal/bootstrap/gcp/errors.go +++ b/internal/bootstrap/gcp/errors.go @@ -18,13 +18,16 @@ func IsNotFoundError(err error) bool { if err == nil { return false } + if status.Code(err) == codes.NotFound { return true } + var apiErr *googleapi.Error if errors.As(err, &apiErr) { return apiErr.Code == 404 } + return false } @@ -33,10 +36,13 @@ func IsSpotCapacityError(err error) bool { if err == nil { return false } + if status.Code(err) == codes.ResourceExhausted { return true } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "zone_resource_pool_exhausted") || strings.Contains(errStr, "unsupported_operation") || strings.Contains(errStr, "stockout") || @@ -48,5 +54,6 @@ func IsAlreadyExistsError(err error) bool { if err == nil { return false } + return status.Code(err) == codes.AlreadyExists || strings.Contains(err.Error(), "already exists") } diff --git a/internal/bootstrap/gcp/gce.go b/internal/bootstrap/gcp/gce.go index 498bfe918..73df5e0f7 100644 --- a/internal/bootstrap/gcp/gce.go +++ b/internal/bootstrap/gcp/gce.go @@ -13,6 +13,7 @@ import ( "time" "cloud.google.com/go/compute/apiv1/computepb" + "github.com/codesphere-cloud/oms/internal/bootstrap/datacenter" "github.com/codesphere-cloud/oms/internal/github" "github.com/codesphere-cloud/oms/internal/installer/node" "github.com/codesphere-cloud/oms/internal/util" @@ -24,18 +25,71 @@ type VMDef struct { Tags []string AdditionalDisks []int64 ExternalIP bool + // DataCenterID is the data center the VM belongs to, or 0 for the project-shared VMs + // (jumpbox and postgres) that every data center uses. + DataCenterID int } -// Example VM definitions (expand as needed) -var vmDefs = []VMDef{ - {"jumpbox", "e2-medium", []string{"jumpbox", "ssh"}, []int64{}, true}, - {"postgres", "e2-standard-2", []string{"postgres"}, []int64{}, true}, - {"ceph-1", "e2-standard-8", []string{"ceph"}, []int64{10, 100}, false}, - {"ceph-2", "e2-standard-8", []string{"ceph"}, []int64{10, 100}, false}, - {"ceph-3", "e2-standard-8", []string{"ceph"}, []int64{10, 100}, false}, - {"k0s-1", "e2-standard-8", []string{"k0s"}, []int64{}, false}, - {"k0s-2", "e2-standard-8", []string{"k0s"}, []int64{}, false}, - {"k0s-3", "e2-standard-8", []string{"k0s"}, []int64{}, false}, +// cephNodesPerDataCenter and k0sNodesPerDataCenter are the per-data-center node counts. Three +// Ceph nodes are the minimum for replication; three k0s nodes give one control plane and three +// workers, as written into the install config. +const ( + cephNodesPerDataCenter = 3 + k0sNodesPerDataCenter = 3 +) + +// sharedVMDefs returns the VMs that exist once per project, regardless of how many data centers +// are bootstrapped. The postgres node hosts the database both data centers share. +func sharedVMDefs() []VMDef { + return []VMDef{ + {Name: "jumpbox", MachineType: "e2-medium", Tags: []string{"jumpbox", "ssh"}, AdditionalDisks: []int64{}, ExternalIP: true}, + {Name: "postgres", MachineType: "e2-standard-2", Tags: []string{"postgres"}, AdditionalDisks: []int64{}, ExternalIP: true}, + } +} + +// dataCenterVMDefs returns the Ceph and k0s VMs of one data center. The suffix is empty for the +// primary data center, so single-DC bootstraps keep the names ceph-1..3 and k0s-1..3. +func dataCenterVMDefs(dcID int, suffix string) []VMDef { + defs := make([]VMDef, 0, cephNodesPerDataCenter+k0sNodesPerDataCenter) + for i := 1; i <= cephNodesPerDataCenter; i++ { + defs = append(defs, VMDef{ + Name: fmt.Sprintf("ceph-%d%s", i, suffix), + MachineType: "e2-standard-8", + Tags: []string{"ceph"}, + AdditionalDisks: []int64{10, 100}, + DataCenterID: dcID, + }) + } + + for i := 1; i <= k0sNodesPerDataCenter; i++ { + defs = append(defs, VMDef{ + Name: fmt.Sprintf("k0s-%d%s", i, suffix), + MachineType: "e2-standard-8", + Tags: []string{"k0s"}, + AdditionalDisks: []int64{}, + DataCenterID: dcID, + }) + } + + return defs +} + +// VMDefsForEnv returns every VM definition of the environment: the project-shared VMs plus the +// Ceph and k0s VMs of each data center. When the environment carries no data centers — as with +// an infra file written before multi-DC support — it falls back to a single unsuffixed one. +func VMDefsForEnv(env *CodesphereEnvironment) []VMDef { + defs := sharedVMDefs() + + dcs := env.DataCenters + if len(dcs) == 0 { + dcs = []*datacenter.DataCenter{{ID: datacenter.PrimaryID}} + } + + for _, dc := range dcs { + defs = append(defs, dataCenterVMDefs(dc.ID, dc.Suffix)...) + } + + return defs } // validateVMProvisioningOptions checks that spot and preemptible options are not both set @@ -43,6 +97,7 @@ func (b *GCPBootstrapper) validateVMProvisioningOptions() error { if b.Env.SpotVMs && b.Env.Preemptible { return fmt.Errorf("cannot specify both --spot-vms and --preemptible flags; use --spot-vms for the newer spot VM model") } + return nil } @@ -51,31 +106,41 @@ type vmResult struct { name string externalIP string internalIP string + dcID int } // EnsureComputeInstances ensures that all required compute instances are present and running. func (b *GCPBootstrapper) EnsureComputeInstances() error { - wg := sync.WaitGroup{} - errCh := make(chan error, len(vmDefs)) - resultCh := make(chan vmResult, len(vmDefs)) - logCh := make(chan string, len(vmDefs)) + if err := b.ensureDataCenters(); err != nil { + return err + } + sshKeys, err := b.getSSHKeys() if err != nil { return fmt.Errorf("failed to determine SSH keys: %w", err) } - for _, vm := range vmDefs { + vms := VMDefsForEnv(b.Env) + wg := sync.WaitGroup{} + errCh := make(chan error, len(vms)) + resultCh := make(chan vmResult, len(vms)) + logCh := make(chan string, len(vms)) + + for _, vm := range vms { wg.Add(1) go func(vm VMDef) { defer wg.Done() + result, err := b.ensureVM(vm, b.Env.RootDiskSize, sshKeys, logCh) if err != nil { errCh <- err return } + resultCh <- result }(vm) } + wg.Wait() close(errCh) @@ -90,6 +155,7 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { for err := range errCh { errs = append(errs, err) } + if len(errs) > 0 { return fmt.Errorf("error ensuring compute instances: %w", errors.Join(errs...)) } @@ -99,6 +165,14 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { NodeClient: b.NodeClient, FileIO: b.fw, } + dcByID := map[int]*datacenter.DataCenter{} + + for _, dc := range b.Env.DataCenters { + dc.CephNodes = nil + dc.ControlPlaneNodes = nil + dcByID[dc.ID] = dc + } + for result := range resultCh { switch result.vmType { case "jumpbox": @@ -106,30 +180,44 @@ func (b *GCPBootstrapper) EnsureComputeInstances() error { case "postgres": b.Env.PostgreSQLNode = b.Env.Jumpbox.CreateSubNode(result.name, result.externalIP, result.internalIP) case "ceph": - node := b.Env.Jumpbox.CreateSubNode(result.name, result.externalIP, result.internalIP) - b.Env.CephNodes = append(b.Env.CephNodes, node) + dc, ok := dcByID[result.dcID] + if !ok { + return fmt.Errorf("instance %s belongs to unknown data center %d", result.name, result.dcID) + } + + dc.CephNodes = append(dc.CephNodes, b.Env.Jumpbox.CreateSubNode(result.name, result.externalIP, result.internalIP)) case "k0s": - node := b.Env.Jumpbox.CreateSubNode(result.name, result.externalIP, result.internalIP) - b.Env.ControlPlaneNodes = append(b.Env.ControlPlaneNodes, node) + dc, ok := dcByID[result.dcID] + if !ok { + return fmt.Errorf("instance %s belongs to unknown data center %d", result.name, result.dcID) + } + + dc.ControlPlaneNodes = append(dc.ControlPlaneNodes, b.Env.Jumpbox.CreateSubNode(result.name, result.externalIP, result.internalIP)) } } - //sort ceph nodes by name to ensure consistent ordering - sort.Slice(b.Env.CephNodes, func(i, j int) bool { - return b.Env.CephNodes[i].GetName() < b.Env.CephNodes[j].GetName() - }) - //sort control plane nodes by name to ensure consistent ordering - sort.Slice(b.Env.ControlPlaneNodes, func(i, j int) bool { - return b.Env.ControlPlaneNodes[i].GetName() < b.Env.ControlPlaneNodes[j].GetName() - }) + // Sort each data center's nodes by name to ensure consistent ordering, since the install + // config assigns roles by index. + for _, dc := range b.Env.DataCenters { + sort.Slice(dc.CephNodes, func(i, j int) bool { + return dc.CephNodes[i].GetName() < dc.CephNodes[j].GetName() + }) + sort.Slice(dc.ControlPlaneNodes, func(i, j int) bool { + return dc.ControlPlaneNodes[i].GetName() < dc.ControlPlaneNodes[j].GetName() + }) + } + + b.mirrorPrimaryDataCenter() return nil } func (b *GCPBootstrapper) getSSHKeys() (string, error) { sshKeys := "" + if b.Env.GitHubPAT != "" && b.Env.GitHubTeamOrg != "" && b.Env.GitHubTeamSlug != "" { var err error + sshKeys, err = github.GetSSHKeysFromGitHubTeam(b.GitHubClient, b.Env.GitHubTeamOrg, b.Env.GitHubTeamSlug) if err != nil { return "", fmt.Errorf("failed to get SSH keys from GitHub team: %w", err) @@ -142,6 +230,7 @@ func (b *GCPBootstrapper) getSSHKeys() (string, error) { } sshKeys += fmt.Sprintf("root:%s\nubuntu:%s", pubKey+"root", pubKey+"ubuntu") + return sshKeys, nil } @@ -170,6 +259,7 @@ func (b *GCPBootstrapper) ensureVM(vm VMDef, rootDiskSize int64, sshKeys string, if err != nil { return vmResult{}, err } + if err := b.CreateInstanceWithFallback(projectID, zone, instance, vm.Name, logCh); err != nil { return vmResult{}, err } @@ -181,11 +271,13 @@ func (b *GCPBootstrapper) ensureVM(vm VMDef, rootDiskSize int64, sshKeys string, } internalIP, externalIP := ExtractInstanceIPs(readyInstance) + return vmResult{ vmType: vm.Tags[0], name: vm.Name, externalIP: externalIP, internalIP: internalIP, + dcID: vm.DataCenterID, }, nil } @@ -274,6 +366,7 @@ func ExtractInstanceIPs(inst *computepb.Instance) (internalIP, externalIP string externalIP = inst.GetNetworkInterfaces()[0].GetAccessConfigs()[0].GetNatIP() } } + return } @@ -283,13 +376,16 @@ func IsInstanceReady(inst *computepb.Instance, needsExternalIP bool) bool { if inst.GetStatus() != "RUNNING" || len(inst.GetNetworkInterfaces()) == 0 { return false } + ni := inst.GetNetworkInterfaces()[0] if ni.GetNetworkIP() == "" { return false } + if needsExternalIP && (len(ni.GetAccessConfigs()) == 0 || ni.GetAccessConfigs()[0].GetNatIP() == "") { return false } + return true } @@ -303,6 +399,7 @@ func (b *GCPBootstrapper) BuildSchedulingConfig() *computepb.Scheduling { InstanceTerminationAction: protoString("STOP"), } } + if b.Env.Preemptible { return &computepb.Scheduling{ Preemptible: protoBool(true), @@ -326,11 +423,14 @@ func (b *GCPBootstrapper) CreateInstanceWithFallback(projectID, zone string, ins if b.Env.SpotVMs && IsSpotCapacityError(err) { logCh <- fmt.Sprintf("Spot capacity unavailable for %s, falling back to standard VM", vmName) + instance.Scheduling = &computepb.Scheduling{} + err = b.GCPClient.CreateInstance(projectID, zone, instance) if err != nil && !IsAlreadyExistsError(err) { return fmt.Errorf("failed to create instance %s (fallback to standard VM): %w", vmName, err) } + return nil } @@ -352,8 +452,10 @@ func (b *GCPBootstrapper) waitForInstanceRunning(projectID, zone, name string, n if attempt < maxAttempts-1 { b.Time.Sleep(pollInterval) } + continue } + return nil, fmt.Errorf("failed to poll instance %s: %w", name, err) } @@ -365,34 +467,40 @@ func (b *GCPBootstrapper) waitForInstanceRunning(projectID, zone, name string, n b.Time.Sleep(pollInterval) } } + return nil, fmt.Errorf("timed out waiting for instance %s to be RUNNING with IPs assigned after %s", name, pollInterval*time.Duration(maxAttempts)) } -// findVMDef looks up a VM definition by name. Returns nil if not found. -func findVMDef(name string) *VMDef { - for _, vm := range vmDefs { - if vm.Name == name { - return &vm +// findVMDef looks up a VM definition by name among the given definitions. Returns nil if not +// found. +func findVMDef(defs []VMDef, name string) *VMDef { + for i := range defs { + if defs[i].Name == name { + return &defs[i] } } + return nil } -// validVMNames returns the list of known VM names from vmDefs. -func validVMNames() []string { - names := make([]string, len(vmDefs)) - for i, vm := range vmDefs { +// validVMNames returns the names of the given VM definitions. +func validVMNames(defs []VMDef) []string { + names := make([]string, len(defs)) + for i, vm := range defs { names[i] = vm.Name } + return names } -// RestartVM restarts a single stopped or terminated VM by a name that is defined in vmDefs. +// RestartVM restarts a single stopped or terminated VM by a name defined for this environment. func (b *GCPBootstrapper) RestartVM(name string) error { - vm := findVMDef(name) + defs := VMDefsForEnv(b.Env) + + vm := findVMDef(defs, name) if vm == nil { - return fmt.Errorf("unknown VM name %q; valid names are: %s", name, strings.Join(validVMNames(), ", ")) + return fmt.Errorf("unknown VM name %q; valid names are: %s", name, strings.Join(validVMNames(defs), ", ")) } projectID := b.Env.ProjectID @@ -403,6 +511,7 @@ func (b *GCPBootstrapper) RestartVM(name string) error { if IsNotFoundError(err) { return fmt.Errorf("instance %s does not exist in project %s / zone %s; did you run bootstrap first?", name, projectID, zone) } + return fmt.Errorf("failed to get instance %s: %w", name, err) } @@ -412,6 +521,7 @@ func (b *GCPBootstrapper) RestartVM(name string) error { return nil case "TERMINATED", "STOPPED": log.Printf("Starting stopped instance %s...", name) + if err := b.GCPClient.StartInstance(projectID, zone, name); err != nil { return fmt.Errorf("failed to start instance %s: %w", name, err) } @@ -428,34 +538,41 @@ func (b *GCPBootstrapper) RestartVM(name string) error { internalIP, externalIP := ExtractInstanceIPs(readyInstance) log.Printf("Instance %s is now running (internal=%s, external=%s)", name, internalIP, externalIP) + return nil } -// RestartVMs restarts all stopped or terminated VMs defined in vmDefs. +// RestartVMs restarts all stopped or terminated VMs of the environment, across every data center. func (b *GCPBootstrapper) RestartVMs() error { var errs []error - for _, vm := range vmDefs { + + for _, vm := range VMDefsForEnv(b.Env) { if err := b.RestartVM(vm.Name); err != nil { errs = append(errs, err) } } + if len(errs) > 0 { return fmt.Errorf("errors restarting VMs: %w", errors.Join(errs...)) } + return nil } // ReadSSHKey reads an SSH key file, expanding ~ in the path func (b *GCPBootstrapper) ReadSSHKey(path string) (string, error) { realPath := util.ExpandPath(path) + data, err := b.fw.ReadFile(realPath) if err != nil { return "", fmt.Errorf("error reading SSH key from %s: %w", realPath, err) } + key := strings.TrimSpace(string(data)) if key == "" { return "", fmt.Errorf("SSH key at %s is empty", realPath) } + return key, nil } diff --git a/internal/bootstrap/gcp/gce_test.go b/internal/bootstrap/gcp/gce_test.go index 5767dc78e..5737e8e3e 100644 --- a/internal/bootstrap/gcp/gce_test.go +++ b/internal/bootstrap/gcp/gce_test.go @@ -12,7 +12,6 @@ import ( "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" "github.com/codesphere-cloud/oms/internal/github" "github.com/codesphere-cloud/oms/internal/util" - gh "github.com/google/go-github/v74/github" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -22,6 +21,60 @@ import ( ) var _ = Describe("GCE", func() { + Describe("VMDefsForEnv", func() { + It("keeps the names a single-data-center bootstrap has always used", func() { + env := &gcp.CodesphereEnvironment{} + env.DataCenters = gcp.BuildDataCenters(env) + + defs := gcp.VMDefsForEnv(env) + + Expect(vmNames(defs)).To(Equal([]string{ + "jumpbox", "postgres", + "ceph-1", "ceph-2", "ceph-3", + "k0s-1", "k0s-2", "k0s-3", + })) + }) + + It("adds suffixed ceph and k0s nodes per additional data center", func() { + env := &gcp.CodesphereEnvironment{MultiDC: true} + env.DataCenters = gcp.BuildDataCenters(env) + + defs := gcp.VMDefsForEnv(env) + + Expect(vmNames(defs)).To(Equal([]string{ + "jumpbox", "postgres", + "ceph-1", "ceph-2", "ceph-3", + "k0s-1", "k0s-2", "k0s-3", + "ceph-1-dc2", "ceph-2-dc2", "ceph-3-dc2", + "k0s-1-dc2", "k0s-2-dc2", "k0s-3-dc2", + })) + }) + + It("assigns the shared VMs to no data center and the rest to theirs", func() { + env := &gcp.CodesphereEnvironment{MultiDC: true} + env.DataCenters = gcp.BuildDataCenters(env) + + byName := map[string]int{} + for _, def := range gcp.VMDefsForEnv(env) { + byName[def.Name] = def.DataCenterID + } + + Expect(byName["jumpbox"]).To(BeZero()) + Expect(byName["postgres"]).To(BeZero()) + Expect(byName["ceph-1"]).To(Equal(1)) + Expect(byName["k0s-3"]).To(Equal(1)) + Expect(byName["ceph-1-dc2"]).To(Equal(2)) + Expect(byName["k0s-3-dc2"]).To(Equal(2)) + }) + + // Infra files written before multi-DC support carry no data center list. + It("falls back to a single unsuffixed data center when the environment has none", func() { + defs := gcp.VMDefsForEnv(&gcp.CodesphereEnvironment{}) + + Expect(vmNames(defs)).To(ContainElement("k0s-1")) + Expect(vmNames(defs)).To(HaveLen(8)) + }) + }) Describe("IsNotFoundError", func() { Context("when error is nil", func() { @@ -296,6 +349,7 @@ var _ = Describe("GCE", func() { DescribeTable("falls back to standard VM on capacity errors", func(capacityErr error) { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything).Return(capacityErr).Once() gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything).Return(nil).Once() @@ -311,6 +365,7 @@ var _ = Describe("GCE", func() { It("clears scheduling config on fallback", func() { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). Return(fmt.Errorf("ZONE_RESOURCE_POOL_EXHAUSTED")).Once() gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.MatchedBy(func(inst *computepb.Instance) bool { @@ -324,6 +379,7 @@ var _ = Describe("GCE", func() { It("returns error with context when fallback also fails", func() { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). Return(fmt.Errorf("ZONE_RESOURCE_POOL_EXHAUSTED")).Once() gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). @@ -337,6 +393,7 @@ var _ = Describe("GCE", func() { It("does NOT fall back on non-capacity errors", func() { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). Return(fmt.Errorf("permission denied")).Once() @@ -349,6 +406,7 @@ var _ = Describe("GCE", func() { It("succeeds when fallback retry returns AlreadyExists", func() { instance := spotInstance("test-vm") + gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). Return(grpcstatus.Errorf(codes.ResourceExhausted, "exhausted")).Once() gc.EXPECT().CreateInstance("test-pid", "us-central1-a", mock.Anything). @@ -506,6 +564,7 @@ var _ = Describe("GCE", func() { It("reads and trims SSH key", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAAA... \n"), nil) + key, err := bs.ReadSSHKey("~/.ssh/id_rsa.pub") Expect(err).NotTo(HaveOccurred()) Expect(key).To(Equal("ssh-rsa AAAA...")) @@ -513,6 +572,7 @@ var _ = Describe("GCE", func() { It("returns error when file read fails", func() { fw.EXPECT().ReadFile(mock.Anything).Return(nil, fmt.Errorf("no such file")) + _, err := bs.ReadSSHKey("~/.ssh/missing.pub") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("error reading SSH key")) @@ -520,6 +580,7 @@ var _ = Describe("GCE", func() { It("returns error when key file is empty", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte(" \n "), nil) + _, err := bs.ReadSSHKey("~/.ssh/empty.pub") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("is empty")) @@ -568,8 +629,10 @@ var _ = Describe("GCE", func() { It("Sets the root disk size", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAA..."), nil).Times(1) + allRootDiskSizesCorrect := true mu := sync.Mutex{} + gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn( // Testing the disk size like this instead of a matcher // to avoid the test to panic in case of a mismatch in the parallel go funcs @@ -579,6 +642,7 @@ var _ = Describe("GCE", func() { allRootDiskSizesCorrect = false mu.Unlock() } + return nil }, ).Times(8) @@ -592,6 +656,7 @@ var _ = Describe("GCE", func() { It("creates all instances", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAA..."), nil).Times(1) gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil).Times(8) + ipResp := makeRunningInstance("10.0.0.x", "1.2.3.x") mockGetInstanceNotFoundThenRunning(gc, csEnv.ProjectID, csEnv.Zone, ipResp, 8) @@ -623,22 +688,25 @@ var _ = Describe("GCE", func() { csEnv.GitHubTeamSlug = "dev" }) It("fetches GitHub team keys", func() { - mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return([]*gh.User{{Login: gh.Ptr("alice")}}, nil).Maybe() - mockGitHubClient.EXPECT().ListUserKeys(mock.Anything, "alice").Return([]*gh.Key{{Key: gh.Ptr("ssh-rsa AAALICE...")}}, nil).Maybe() + mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug).Return([]github.TeamMemberKeys{{Login: "alice", Keys: []string{"ssh-rsa AAALICE..."}}}, nil).Maybe() + ipResp := makeRunningInstance("10.0.0.x", "1.2.3.x") mockGetInstanceNotFoundThenRunning(gc, csEnv.ProjectID, csEnv.Zone, ipResp, 8) fw.EXPECT().ReadFile(csEnv.SSHPublicKeyPath).Return([]byte("ssh-rsa AAA..."), nil).Times(1) gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone string, instance *computepb.Instance) error { sshMetadata := "" + for _, item := range instance.GetMetadata().GetItems() { if item.GetKey() == "ssh-keys" { sshMetadata = item.GetValue() } } + if !strings.Contains(sshMetadata, "AAALICE...") { return fmt.Errorf("expected ssh metadata to include team user key") } + return nil }).Times(8) @@ -648,7 +716,7 @@ var _ = Describe("GCE", func() { It("fails when GitHub client fails to list team members", func() { gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil, grpcstatus.Errorf(codes.NotFound, "not found")).Maybe() - mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug, mock.Anything).Return(nil, fmt.Errorf("list members error")).Maybe() + mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, csEnv.GitHubTeamOrg, csEnv.GitHubTeamSlug).Return(nil, fmt.Errorf("list members error")).Maybe() err := bs.EnsureComputeInstances() Expect(err).To(HaveOccurred()) @@ -682,15 +750,19 @@ var _ = Describe("GCE", func() { It("fails when GetInstance fails after creation", func() { instanceCalls := make(map[string]int) + var mu sync.Mutex + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn( func(projectID, zone, name string) (*computepb.Instance, error) { mu.Lock() defer mu.Unlock() + instanceCalls[name]++ if instanceCalls[name] == 1 { return nil, notFoundErr } + return nil, fmt.Errorf("get error") }, ).Maybe() @@ -732,15 +804,20 @@ var _ = Describe("GCE", func() { fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAA..."), nil).Times(1) createCalls := make(map[string]int) + var mu sync.Mutex + gc.EXPECT().CreateInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone string, instance *computepb.Instance) error { mu.Lock() defer mu.Unlock() + name := *instance.Name + createCalls[name]++ if createCalls[name] == 1 { return fmt.Errorf("ZONE_RESOURCE_POOL_EXHAUSTED") } + return nil }).Times(16) @@ -750,12 +827,16 @@ var _ = Describe("GCE", func() { It("restarts stopped VMs instead of creating new ones", func() { instanceCalls := make(map[string]int) + var mu sync.Mutex + stoppedResp := makeStoppedInstance("10.0.0.x", "1.2.3.x") runningResp := makeRunningInstance("10.0.0.x", "1.2.3.x") + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone, name string) (*computepb.Instance, error) { mu.Lock() defer mu.Unlock() + instanceCalls[name]++ if instanceCalls[name] == 1 { // First call, VM exists but is stopped @@ -784,13 +865,17 @@ var _ = Describe("GCE", func() { It("handles VMs in intermediate states (STAGING/PROVISIONING)", func() { instanceCalls := make(map[string]int) + var mu sync.Mutex + stagingResp := makeInstance("STAGING", "10.0.0.x", "1.2.3.x") runningResp := makeRunningInstance("10.0.0.x", "1.2.3.x") + fw.EXPECT().ReadFile(mock.Anything).Return([]byte("ssh-rsa AAAA... \n"), nil) gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(projectID, zone, name string) (*computepb.Instance, error) { mu.Lock() defer mu.Unlock() + instanceCalls[name]++ if instanceCalls[name] == 1 { // First call: instance exists but is still staging @@ -961,11 +1046,13 @@ var _ = Describe("GCE", func() { runningInst := makeRunningInstance("10.0.0.1", "1.2.3.4") callCounts := map[string]int{} + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).RunAndReturn(func(_, _, name string) (*computepb.Instance, error) { callCounts[name]++ if callCounts[name] == 1 { return stoppedInst, nil } + return runningInst, nil }).Times(16) gc.EXPECT().StartInstance(csEnv.ProjectID, csEnv.Zone, mock.Anything).Return(nil).Times(8) diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 8e8550d93..750c27f69 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -8,13 +8,13 @@ import ( "errors" "fmt" "log" - "path/filepath" "slices" "strings" "time" "cloud.google.com/go/compute/apiv1/computepb" "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/bootstrap/datacenter" "github.com/codesphere-cloud/oms/internal/clusteradmin" "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/github" @@ -28,21 +28,54 @@ import ( "google.golang.org/api/dns/v1" ) +// RegistryType is a custom type to define which registry is used in the bootstrapper type RegistryType string const ( - RegistryTypeLocalContainer RegistryType = "local-container" + // RegistryTypeLocalContainer runs a container registry inside the cluster, + // which is set up on the nodes during bootstrapping. + // Used for air-gapped installation + RegistryTypeLocalContainer RegistryType = "local-container" + + // RegistryTypeArtifactRegistry uses GCP Artifact Registry. Bootstrapping + // creates the registry, a dedicated service account with writer + // permissions, and stores its key as the registry credentials. RegistryTypeArtifactRegistry RegistryType = "artifact-registry" - RegistryTypeGitHub RegistryType = "github" + + // RegistryTypeGitHub pulls images directly from the GitHub container + // registry. Since no images need to be loaded into a self-hosted registry, + // bootstrapping only configures GitHub access and installs the lite + // package. + RegistryTypeGitHub RegistryType = "github" ) +// remoteK0sConfigScriptPath is where each data center's k0s configuration script is placed on +// that data center's first control plane node. Data centers have separate nodes, so the path can +// be the same for all of them. +const remoteK0sConfigScriptPath = "/root/configure-k0s.sh" + +// installerNodeSecretsDir is where the Codesphere installer uploads a data center's age key on +// every one of that data center's nodes. The path is fixed even though the installer reads the key +// from the data center's own secrets.baseDir on the jumpbox, and the installer only creates +// baseDir on the node — so for a data center whose baseDir differs, the upload target would not +// exist. Creating it up front is harmless: data centers have separate nodes, so a node only ever +// holds its own data center's key. +const installerNodeSecretsDir = "/etc/codesphere/secrets" + +// vpcSubnetCIDR is the range of the project's single subnet, shared by all data centers. It is +// also each data center's ceph.nodesSubnet; their Ceph clusters stay separate because each has +// its own hosts, monitors and FSID. +const vpcSubnetCIDR = "10.10.0.0/20" + // CheckOMSManagedLabel checks if the given labels map indicates an OMS-managed project. // A project is considered OMS-managed if it has the 'oms-managed' label set to "true". func CheckOMSManagedLabel(labels map[string]string) bool { if labels == nil { return false } + value, exists := labels[OMSManagedLabel] + return exists && value == "true" } @@ -81,6 +114,7 @@ var DefaultPreviewFlags []string = []string{ "secret-management", "sub-path-mount", "workspace-ssh", + "virtual-machines", } var DefaultFeatureFlags []string = []string{} @@ -100,16 +134,52 @@ type GCPBootstrapper struct { GitHubClient github.GitHubClient } +// primaryDC returns the first data center, which owns the shared PostgreSQL server and the +// platform gateway that codesphere.domain resolves to. +func (b *GCPBootstrapper) primaryDC() *datacenter.DataCenter { + return b.Env.DataCenters[0] +} + +// allNodes returns every node of the project: the jumpbox, the shared postgres node and all +// data centers' Ceph and k0s nodes. +func (b *GCPBootstrapper) allNodes() []*node.Node { + nodes := []*node.Node{b.Env.Jumpbox, b.Env.PostgreSQLNode} + return append(nodes, b.clusterNodes()...) +} + +// clusterNodes returns every Ceph and k0s node of all data centers, i.e. all nodes except the +// jumpbox and the shared postgres node. +func (b *GCPBootstrapper) clusterNodes() []*node.Node { + nodes := []*node.Node{} + for _, dc := range b.Env.DataCenters { + nodes = append(nodes, dc.ControlPlaneNodes...) + nodes = append(nodes, dc.CephNodes...) + } + + return nodes +} + type CodesphereEnvironment struct { - ProjectID string `json:"project_id"` - ProjectTTL string `json:"project_ttl"` - ProjectName string `json:"project_name"` - DNSProjectID string `json:"dns_project_id"` - Jumpbox *node.Node `json:"jumpbox"` - PostgreSQLNode *node.Node `json:"postgres_node"` - ControlPlaneNodes []*node.Node `json:"control_plane_nodes"` - CephNodes []*node.Node `json:"ceph_nodes"` - ContainerRegistryURL string `json:"-"` + ProjectID string `json:"project_id"` + ProjectTTL string `json:"project_ttl"` + ProjectName string `json:"project_name"` + DNSProjectID string `json:"dns_project_id"` + Jumpbox *node.Node `json:"jumpbox"` + PostgreSQLNode *node.Node `json:"postgres_node"` + // MultiDC bootstraps two data centers that share the PostgreSQL server but run separate + // Kubernetes and Ceph clusters. + MultiDC bool `json:"multi_dc"` + // DataCenters holds the per-data-center state. It always has at least one entry. + DataCenters []*datacenter.DataCenter `json:"datacenters"` + // ControlPlaneNodes and CephNodes are where the primary data center's nodes lived before + // multi-DC support. The steps that have not been migrated to DataCenters yet still use + // them, and infra files written by an earlier OMS carry the nodes here. + ControlPlaneNodes []*node.Node `json:"control_plane_nodes"` + CephNodes []*node.Node `json:"ceph_nodes"` + // ContainerRegistryURL is the resolved registry server all data centers pull images from. + ContainerRegistryURL string `json:"container_registry_url,omitempty"` + RegistryUsername string `json:"-"` + RegistryPassword string `json:"-"` ExistingConfigUsed bool `json:"-"` InstallVersion string `json:"install_version"` InstallLocal string `json:"install_local"` @@ -183,10 +253,13 @@ type CodesphereEnvironment struct { SSHPrivateKeyPath string `json:"-"` DatacenterID int `json:"-"` DatacenterName string `json:"-"` - CustomPgIP string `json:"custom_pg_ip"` - Region string `json:"region"` - Zone string `json:"zone"` - DNSZoneName string `json:"dns_zone_name"` + // DatacenterIDExplicit records whether --datacenter-id was set on the command line. The + // value alone cannot distinguish the default 1 from an explicit 1. + DatacenterIDExplicit bool `json:"-"` + CustomPgIP string `json:"custom_pg_ip"` + Region string `json:"region"` + Zone string `json:"zone"` + DNSZoneName string `json:"dns_zone_name"` // Test user creation CreateTestUser bool `json:"-"` @@ -194,6 +267,13 @@ type CodesphereEnvironment struct { RootDiskSize int64 `json:"root_disk_size"` // Local OMS binary copied to the jumpbox instead of installing a release. RemoteOmsBinaryPath string `json:"-"` + + // OpenFGA database backups. The bucket lives in the project and is removed + // together with the project on cleanup. Access key/secret are populated only + // when a new HMAC key is created. + OpenfgaBackupBucket string `json:"openfga_backup_bucket"` + OpenfgaBackupAccessKeyID string `json:"-"` + OpenfgaBackupSecret string `json:"-"` } func NewGCPBootstrapper( @@ -276,6 +356,11 @@ func (b *GCPBootstrapper) Bootstrap() error { return fmt.Errorf("failed to ensure IAM roles: %w", err) } + err = b.stlog.Step("Ensure openfga backup bucket", b.EnsureOpenfgaBackupBucket) + if err != nil { + return fmt.Errorf("failed to ensure openfga backup bucket: %w", err) + } + err = b.stlog.Step("Ensure VPC", b.EnsureVPC) if err != nil { return fmt.Errorf("failed to ensure VPC: %w", err) @@ -347,28 +432,20 @@ func (b *GCPBootstrapper) Bootstrap() error { return fmt.Errorf("failed to ensure DNS records: %w", err) } - err = b.stlog.Step("Generate k0s config script", b.GenerateK0sConfigScript) - if err != nil { - return fmt.Errorf("failed to generate k0s config script: %w", err) - } - if b.Env.InstallVersion != "" || b.Env.InstallLocal != "" { - err = b.stlog.Step("Install k0s", b.InstallK0s) + err = b.EnsureK0s() if err != nil { return fmt.Errorf("failed to install k0s: %w", err) } - err = b.stlog.Step("Wait for k0s nodes", b.WaitForK0sNodes) - if err != nil { - return fmt.Errorf("failed waiting for k0s nodes: %w", err) - } - - err = b.stlog.Step("Install Codesphere", b.InstallCodesphere) + err = b.InstallCodesphere() if err != nil { return fmt.Errorf("failed to install Codesphere: %w", err) } - err = b.stlog.Step("Run k0s config script", b.RunK0sConfigScript) + // Every data center is installed before any k0s script runs, so a script never patches + // gateway services an install has yet to create. + err = b.RunK0sConfigScript() if err != nil { return fmt.Errorf("failed to run k0s config script: %w", err) } @@ -383,8 +460,13 @@ func (b *GCPBootstrapper) Bootstrap() error { return nil } -// createTestUser creates a test user in the PostgreSQL instance using the testuser package and logs the credentials. +// createTestUser creates a test user in the shared PostgreSQL instance using the testuser package +// and logs the credentials. The user's team is homed in the primary data center. func (b *GCPBootstrapper) createTestUser() error { + if err := b.ensureDataCenters(); err != nil { + return err + } + if b.Env.PostgreSQLNode == nil { return fmt.Errorf("postgres node not found in bootstrap environment") } @@ -394,13 +476,16 @@ func (b *GCPBootstrapper) createTestUser() error { return fmt.Errorf("postgres node has no external IP") } - if b.Env.InstallConfig == nil { + primary := b.primaryDC() + if primary.InstallConfig == nil { return fmt.Errorf("install config not found in bootstrap environment") } - pgPasswordSecret := b.icg.GetVault().GetSecret(files.SecretPostgresPassword) + + pgPasswordSecret := primary.ConfigManager.GetVault().GetSecret(files.SecretPostgresPassword) if pgPasswordSecret == nil || pgPasswordSecret.Fields == nil { return fmt.Errorf("postgres admin password not found in vault") } + pgPassword := pgPasswordSecret.Fields.Password result, err := testuser.CreateTestUser(testuser.CreateTestUserOpts{ @@ -410,15 +495,17 @@ func (b *GCPBootstrapper) createTestUser() error { Password: pgPassword, DBName: testuser.DefaultDBName, SSLMode: "require", - DatacenterID: b.Env.DatacenterID, + DatacenterID: primary.ID, }) if err != nil { return err } testuser.LogAndPersistResult(result, b.Env.OmsWorkdir) + return nil } + func (b *GCPBootstrapper) ValidateInput() error { if b.Env.GoogleACMEIssuer && b.Env.ACMEStaging { return fmt.Errorf("acme-staging cannot be combined with google-acme-issuer") @@ -486,6 +573,7 @@ func (b *GCPBootstrapper) validateClusterAdminEmail() error { if err != nil { return fmt.Errorf("invalid cluster admin email: %w", err) } + b.Env.ClusterAdminEmail = email return nil @@ -497,14 +585,18 @@ func (b *GCPBootstrapper) validateInstallVersion() error { if b.Env.InstallVersion != "" || b.Env.InstallHash != "" { return fmt.Errorf("cannot specify both install-local and install-version/install-hash") } + if !b.fw.Exists(b.Env.InstallLocal) { return fmt.Errorf("local installer package not found at path: %s", b.Env.InstallLocal) } + return nil } + if b.Env.InstallVersion == "" { return nil } + build, err := b.PortalClient.GetBuild(portal.CodesphereProduct, b.Env.InstallVersion, b.Env.InstallHash) if err != nil { return fmt.Errorf("failed to get codesphere package: %w", err) @@ -518,6 +610,7 @@ func (b *GCPBootstrapper) validateInstallVersion() error { if b.Env.RegistryType == RegistryTypeGitHub { requiredFilename = "installer-lite.tar.gz" } + filenames := []string{} // Validate required file exists in package artifacts for _, artifact := range build.Artifacts { @@ -565,6 +658,7 @@ func (b *GCPBootstrapper) validateGitProviderParams() error { if p.id != "" && p.secret == "" { return fmt.Errorf("%s client ID is set but client secret is missing", p.name) } + if p.secret != "" && p.id == "" { return fmt.Errorf("%s client secret is set but client ID is missing", p.name) } @@ -599,9 +693,11 @@ func (b *GCPBootstrapper) validatePrometheusRemoteWriteParams() error { if b.Env.PrometheusRemoteWriteURL != "" && (b.Env.PrometheusRemoteWriteUser == "" || b.Env.PrometheusRemoteWritePassword == "") { return fmt.Errorf("prometheus remote write username and password must both be set when remote write URL is specified") } + if (b.Env.PrometheusRemoteWriteUser != "" || b.Env.PrometheusRemoteWritePassword != "") && b.Env.PrometheusRemoteWriteURL == "" { return fmt.Errorf("prometheus remote write URL is required when remote write username or password is set") } + return nil } @@ -613,6 +709,7 @@ func (b *GCPBootstrapper) validateTelemetryExportParams() error { if b.Env.CentralOtelUsername != "" && b.Env.CentralOtelPassword == "" { return fmt.Errorf("central OTel username is set but password is missing") } + if b.Env.CentralOtelPassword != "" && b.Env.CentralOtelUsername == "" { return fmt.Errorf("central OTel password is set but username is missing") } @@ -642,10 +739,12 @@ func (b *GCPBootstrapper) ensureDnsPermissions() error { if b.Env.DNSProjectID == "" { dnsProject = b.Env.ProjectID } + err := b.ensureIAMRoleWithRetry(dnsProject, "cloud-controller", b.Env.ProjectID, []string{"roles/dns.admin"}) if err != nil { return err } + return nil } @@ -683,6 +782,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { TargetTags: []string{"ssh"}, Description: protoString("Allow external SSH to Jumpbox"), } + err := b.GCPClient.CreateFirewallRule(b.Env.ProjectID, sshRule) if err != nil { return fmt.Errorf("failed to create jumpbox ssh firewall rule: %w", err) @@ -697,9 +797,10 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { Allowed: []*computepb.Allowed{ {IPProtocol: protoString("all")}, }, - SourceRanges: []string{"10.10.0.0/20"}, + SourceRanges: []string{vpcSubnetCIDR}, Description: protoString("Allow all internal traffic"), } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, internalRule) if err != nil { return fmt.Errorf("failed to create internal firewall rule: %w", err) @@ -717,6 +818,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { DestinationRanges: []string{"0.0.0.0/0"}, Description: protoString("Allow all egress"), } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, egressRule) if err != nil { return fmt.Errorf("failed to create egress firewall rule: %w", err) @@ -734,6 +836,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { SourceRanges: []string{"0.0.0.0/0"}, Description: protoString("Allow HTTP/HTTPS ingress"), } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, webRule) if err != nil { return fmt.Errorf("failed to create web firewall rule: %w", err) @@ -752,6 +855,7 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { TargetTags: []string{"postgres"}, Description: protoString("Allow external access to PostgreSQL"), } + err = b.GCPClient.CreateFirewallRule(b.Env.ProjectID, postgresRule) if err != nil { return fmt.Errorf("failed to create postgres firewall rule: %w", err) @@ -760,19 +864,40 @@ func (b *GCPBootstrapper) EnsureFirewallRules() error { return nil } -// EnsureGatewayIPAddresses reserves the static external IP addresses for the ingress -// controllers of the cluster (gateway and public gateway) and the SSH workspace proxy. +// EnsureGatewayIPAddresses reserves the static external IP addresses of every data center: the +// ingress controllers of its cluster (gateway and public gateway) and its SSH workspace proxy. func (b *GCPBootstrapper) EnsureGatewayIPAddresses() error { + if err := b.ensureDataCenters(); err != nil { + return err + } + + for _, dc := range b.Env.DataCenters { + if err := b.ensureGatewayIPAddresses(dc); err != nil { + return err + } + } + + b.mirrorPrimaryDataCenter() + + return nil +} + +// ensureGatewayIPAddresses reserves one data center's static external IP addresses. Their names +// carry the data-center suffix, so the primary data center keeps the unsuffixed names. +func (b *GCPBootstrapper) ensureGatewayIPAddresses(dc *datacenter.DataCenter) error { var err error - b.Env.GatewayIP, err = b.EnsureExternalIP("gateway") + + dc.GatewayIP, err = b.EnsureExternalIP("gateway" + dc.Suffix) if err != nil { return fmt.Errorf("failed to ensure gateway IP: %w", err) } - b.Env.PublicGatewayIP, err = b.EnsureExternalIP("public-gateway") + + dc.PublicGatewayIP, err = b.EnsureExternalIP("public-gateway" + dc.Suffix) if err != nil { return fmt.Errorf("failed to ensure public gateway IP: %w", err) } - b.Env.SshProxyIP, err = b.EnsureExternalIP("ssh-proxy") + + dc.SSHProxyIP, err = b.EnsureExternalIP("ssh-proxy" + dc.Suffix) if err != nil { return fmt.Errorf("failed to ensure ssh proxy IP: %w", err) } @@ -813,14 +938,11 @@ func (b *GCPBootstrapper) EnsureExternalIP(name string) (string, error) { } func (b *GCPBootstrapper) EnsureRootLoginEnabled() error { - allNodes := []*node.Node{ - b.Env.Jumpbox, + if err := b.ensureDataCenters(); err != nil { + return err } - allNodes = append(allNodes, b.Env.ControlPlaneNodes...) - allNodes = append(allNodes, b.Env.PostgreSQLNode) - allNodes = append(allNodes, b.Env.CephNodes...) - for _, node := range allNodes { + for _, node := range b.allNodes() { err := b.stlog.Substep(fmt.Sprintf("Ensuring root login enabled on %s", node.GetName()), func() error { return b.ensureRootLoginEnabledInNode(node) }) @@ -848,9 +970,11 @@ func (b *GCPBootstrapper) ensureRootLoginEnabledInNode(node *node.Node) error { if err == nil { break } + if i == 2 { return fmt.Errorf("failed to enable root login on %s: %w", node.GetName(), err) } + b.stlog.LogRetry() b.Time.Sleep(10 * time.Second) } @@ -890,6 +1014,7 @@ func (b *GCPBootstrapper) EnsureOmsInstalled() (err error) { if err != nil { return fmt.Errorf("failed to make local OMS binary executable on jumpbox: %w", err) } + return nil } @@ -906,8 +1031,11 @@ func (b *GCPBootstrapper) EnsureOmsInstalled() (err error) { } func (b *GCPBootstrapper) EnsureHostsConfigured() error { - allNodes := append(b.Env.ControlPlaneNodes, b.Env.PostgreSQLNode) - allNodes = append(allNodes, b.Env.CephNodes...) + if err := b.ensureDataCenters(); err != nil { + return err + } + + allNodes := append([]*node.Node{b.Env.PostgreSQLNode}, b.clusterNodes()...) for _, node := range allNodes { if !node.HasInotifyWatchesConfigured() { @@ -916,12 +1044,34 @@ func (b *GCPBootstrapper) EnsureHostsConfigured() error { return fmt.Errorf("failed to configure inotify watches on %s: %w", node.GetName(), err) } } + if !node.HasMemoryMapConfigured() { err := node.ConfigureMemoryMap() if err != nil { return fmt.Errorf("failed to configure memory map on %s: %w", node.GetName(), err) } } + + err := node.RunSSHCommand("root", "mkdir -p "+installerNodeSecretsDir) + if err != nil { + return fmt.Errorf("failed to create secrets directory on %s: %w", node.GetName(), err) + } + } + + // A secondary data center's secrets directory differs from the fixed path above, so create that + // one too on its own nodes. Nodes belong to exactly one data center, so no node gets a foreign + // data center's directory. + for _, dc := range b.Env.DataCenters { + if dc.SecretsDir == installerNodeSecretsDir { + continue + } + + for _, n := range append(append([]*node.Node{}, dc.ControlPlaneNodes...), dc.CephNodes...) { + err := n.RunSSHCommand("root", "mkdir -p "+dc.SecretsDir) + if err != nil { + return fmt.Errorf("failed to create secrets directory on %s: %w", n.GetName(), err) + } + } } return nil @@ -933,16 +1083,20 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { // Figure out if registry is already running b.stlog.Logf("Checking if local container registry is already running on postgres node") + checkCommand := `test "$(podman ps --filter 'name=registry' --format '{{.Names}}' | wc -l)" -eq "1"` err := b.Env.PostgreSQLNode.RunSSHCommand("root", checkCommand) registryUsername := "" registryPassword := "" + if s := b.icg.GetVault().GetSecret(files.SecretRegistryUsername); s != nil && s.Fields != nil { registryUsername = s.Fields.Password } + if s := b.icg.GetVault().GetSecret(files.SecretRegistryPassword); s != nil && s.Fields != nil { registryPassword = s.Fields.Password } + if err == nil && b.Env.InstallConfig.Registry != nil && b.Env.InstallConfig.Registry.Server == localRegistryServer && registryUsername != "" && registryPassword != "" { b.stlog.Logf("Local container registry already running on postgres node") @@ -952,6 +1106,7 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { b.Env.InstallConfig.Registry.Server = localRegistryServer registryUsername = "custom-registry" registryPassword = shortuuid.New() + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryUsername, Fields: &files.SecretFields{Password: registryUsername}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryPassword, Fields: &files.SecretFields{Password: registryPassword}}) @@ -978,6 +1133,7 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { } for _, cmd := range commands { b.stlog.Logf("Running command on postgres node: %s", util.Truncate(cmd, 12)) + err := b.Env.PostgreSQLNode.RunSSHCommand("root", cmd) if err != nil { return fmt.Errorf("failed to run command on postgres node: %w", err) @@ -987,14 +1143,17 @@ func (b *GCPBootstrapper) EnsureLocalContainerRegistry() error { allNodes := append(b.Env.ControlPlaneNodes, b.Env.CephNodes...) for _, node := range allNodes { b.stlog.Logf("Configuring node '%s' to trust local registry certificate", node.GetName()) + err := b.Env.PostgreSQLNode.RunSSHCommand("root", "scp -o StrictHostKeyChecking=no /root/registry.crt root@"+node.GetInternalIP()+":/usr/local/share/ca-certificates/registry.crt") if err != nil { return fmt.Errorf("failed to copy registry certificate to node %s: %w", node.GetInternalIP(), err) } + err = node.RunSSHCommand("root", "update-ca-certificates") if err != nil { return fmt.Errorf("failed to update CA certificates on node %s: %w", node.GetInternalIP(), err) } + err = node.RunSSHCommand("root", "systemctl restart docker.service || true") // docker is probably not yet installed if err != nil { return fmt.Errorf("failed to restart docker service on node %s: %w", node.GetInternalIP(), err) @@ -1008,11 +1167,13 @@ func (b *GCPBootstrapper) EnsureGitHubAccessConfigured() error { if b.Env.GitHubPAT == "" { return fmt.Errorf("GitHub PAT is not set") } + b.Env.InstallConfig.Registry.Server = "ghcr.io" b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryUsername, Fields: &files.SecretFields{Password: b.Env.RegistryUser}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretRegistryPassword, Fields: &files.SecretFields{Password: b.Env.GitHubPAT}}) b.Env.InstallConfig.Registry.ReplaceImagesInBom = false b.Env.InstallConfig.Registry.LoadContainerImages = false + return nil } @@ -1023,6 +1184,7 @@ func (b *GCPBootstrapper) EnsureDNSRecords() error { } zoneName := b.Env.DNSZoneName + err := b.GCPClient.EnsureDNSManagedZone(gcpProject, zoneName, b.Env.BaseDomain+".", "Codesphere DNS zone") if err != nil { return fmt.Errorf("failed to ensure DNS managed zone: %w", err) @@ -1069,40 +1231,26 @@ func (b *GCPBootstrapper) EnsureDNSRecords() error { return nil } +// InstallCodesphere installs Codesphere into every data center from the shared jumpbox, in +// ascending data center order. The order matters: the primary data center's install creates the +// database, roles and schema that the secondary ones reuse. func (b *GCPBootstrapper) InstallCodesphere() error { - if err := b.ensureCodespherePackageOnJumpbox(); err != nil { - return fmt.Errorf("failed to ensure Codesphere package on jumpbox: %w", err) - } - - if err := b.runInstallCommand(b.codespherePackageFilename()); err != nil { - return fmt.Errorf("failed to install Codesphere from jumpbox: %w", err) + if err := b.ensureDataCenters(); err != nil { + return err } - return nil -} - -// InstallK0s deploys k0s with the native OMS installer and stores its -// kubeconfig in the encrypted install vault for the remaining installer steps. -func (b *GCPBootstrapper) InstallK0s() error { - // Reuse matching cached binaries and let k0sctl reconcile normally. Without - // --force, an unchanged cluster remains untouched on bootstrap retries. - installCmd := fmt.Sprintf("oms install k0s --version %s --install-config /etc/codesphere/config.yaml --vault %s --vault-priv-key %s/age_key.txt", - installer.DefaultK0sVersion, filepath.Join(b.Env.SecretsDir, "prod.vault.yaml"), b.Env.SecretsDir) - if err := b.Env.Jumpbox.RunSSHCommand("root", installCmd); err != nil { - return fmt.Errorf("failed to install k0s from jumpbox: %w", err) + if err := b.ensureCodespherePackageOnJumpbox(); err != nil { + return fmt.Errorf("failed to ensure Codesphere package on jumpbox: %w", err) } - return nil -} - -// WaitForK0sNodes restores the readiness barrier from the TypeScript -// Kubernetes setup. k0sctl apply completing is not sufficient for the -// Codesphere charts: all schedulable nodes must be Ready before gateway -// controllers and their admission webhooks are installed. -func (b *GCPBootstrapper) WaitForK0sNodes() error { - const command = "k0s kubectl wait --for=condition=Ready nodes --all --timeout=30m" - if err := b.Env.ControlPlaneNodes[0].RunSSHCommand("root", command); err != nil { - return fmt.Errorf("k0s nodes did not become ready: %w", err) + packageFilename := b.codespherePackageFilename() + for _, dc := range b.Env.DataCenters { + err := b.stlog.Step(dc.StepName("Install Codesphere"), func() error { + return b.runInstallCommand(dc, packageFilename) + }) + if err != nil { + return fmt.Errorf("failed to install Codesphere from jumpbox (data center %d): %w", dc.ID, err) + } } return nil @@ -1144,9 +1292,11 @@ func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() error { if b.Env.InstallHash == "" { return fmt.Errorf("install hash must be set when install version is set") } + b.stlog.Logf("Downloading Codesphere package...") downloadCmd := fmt.Sprintf("oms download package -f %s -H %s %s", b.codespherePackageArchiveName(), b.Env.InstallHash, b.Env.InstallVersion) + err := b.Env.Jumpbox.RunSSHCommand("root", downloadCmd) if err != nil { return fmt.Errorf("failed to download Codesphere package from jumpbox: %w", err) @@ -1155,11 +1305,21 @@ func (b *GCPBootstrapper) ensureCodespherePackageOnJumpbox() error { return nil } -func (b *GCPBootstrapper) runInstallCommand(packageFilename string) error { - b.stlog.Logf("Installing Codesphere...") - installCmd := fmt.Sprintf("oms install codesphere -c /etc/codesphere/config.yaml -k %s/age_key.txt --vault %s -p %s%s", - b.Env.SecretsDir, filepath.Join(b.Env.SecretsDir, "prod.vault.yaml"), packageFilename, b.generateSkipStepsArg()) - return b.Env.Jumpbox.RunSSHCommand("root", installCmd) +func (b *GCPBootstrapper) runInstallCommand(dc *datacenter.DataCenter, packageFilename string) error { + b.stlog.Logf("Installing Codesphere in data center %d...", dc.ID) + + if err := b.Env.Jumpbox.RunSSHCommand("root", b.InstallCommand(dc, packageFilename)); err != nil { + return fmt.Errorf("failed to run install command: %w", err) + } + + return nil +} + +// InstallCommand returns the command that installs Codesphere into the given data center from +// the jumpbox. It is also printed for the operator when the bootstrap does not install itself. +func (b *GCPBootstrapper) InstallCommand(dc *datacenter.DataCenter, packageFilename string) string { + return fmt.Sprintf("oms install codesphere -c %s -k %s --vault %s -p %s%s", + dc.RemoteConfigPath, dc.RemoteAgeKeyPath(), dc.RemoteVaultPath(), packageFilename, b.generateSkipStepsArg()) } func (b *GCPBootstrapper) generateSkipStepsArg() string { @@ -1171,116 +1331,10 @@ func (b *GCPBootstrapper) generateSkipStepsArg() string { if b.Env.RegistryType == RegistryTypeGitHub { skipSteps = util.AppendUnique(skipSteps, "load-container-images") } + if len(skipSteps) == 0 { return "" } return " -s " + strings.Join(skipSteps, ",") } -func (b *GCPBootstrapper) GenerateK0sConfigScript() error { - script := `#!/bin/bash - -cat < cloud.conf -[Global] -project-id = "$PROJECT_ID" -EOF - -cat <> cc-deployment.yaml -apiVersion: apps/v1 -kind: DaemonSet -metadata: - name: cloud-controller-manager - namespace: kube-system - labels: - component: cloud-controller-manager -spec: - selector: - matchLabels: - component: cloud-controller-manager - template: - metadata: - labels: - component: cloud-controller-manager - spec: - serviceAccountName: cloud-controller-manager - containers: - - name: cloud-controller-manager - image: k8scloudprovidergcp/cloud-controller-manager:latest - command: - - /usr/local/bin/cloud-controller-manager - args: - - --v=5 - - --cloud-provider=gce - - --cloud-config=/etc/gce/cloud.conf - - --leader-elect-resource-name=k0s-gcp-ccm - - --use-service-account-credentials=true - - --controllers=cloud-node,cloud-node-lifecycle,service - - --allocate-node-cidrs=false - - --configure-cloud-routes=false - volumeMounts: - - name: cloud-config-volume - mountPath: /etc/gce - readOnly: true - volumes: - - name: cloud-config-volume - configMap: - name: cloud-config - tolerations: - - key: node.cloudprovider.kubernetes.io/uninitialized - value: "true" - effect: NoSchedule - - key: node-role.kubernetes.io/master - effect: NoSchedule - - key: node-role.kubernetes.io/control-plane - effect: NoSchedule -EOF - -KUBECTL="/etc/codesphere/deps/kubernetes/files/k0s kubectl" -$KUBECTL create configmap cloud-config --from-file=cloud.conf -n kube-system -echo alias kubectl=\"$KUBECTL\" >> /root/.bashrc -echo alias k=\"$KUBECTL\" >> /root/.bashrc - -$KUBECTL apply -f https://raw.githubusercontent.com/kubernetes/cloud-provider-gcp/refs/tags/providers/v0.28.2/deploy/packages/default/manifest.yaml - -$KUBECTL apply -f cc-deployment.yaml - -# set loadBalancerIP for public-gateway-controller and gateway-controller -$KUBECTL patch svc public-gateway-controller -n codesphere -p '{"spec": {"loadBalancerIP": "'` + b.Env.PublicGatewayIP + `'"}}' -$KUBECTL patch svc gateway-controller -n codesphere -p '{"spec": {"loadBalancerIP": "'` + b.Env.GatewayIP + `'"}}' - -sed -i 's/k0scontroller/k0scontroller --enable-cloud-provider/g' /etc/systemd/system/k0scontroller.service - -ssh -o StrictHostKeyChecking=no root@` + b.Env.ControlPlaneNodes[1].GetInternalIP() + ` "sed -i 's/k0sworker/k0sworker --enable-cloud-provider/g' /etc/systemd/system/k0sworker.service; systemctl daemon-reload; systemctl restart k0sworker" - -ssh -o StrictHostKeyChecking=no root@` + b.Env.ControlPlaneNodes[2].GetInternalIP() + ` "sed -i 's/k0sworker/k0sworker --enable-cloud-provider/g' /etc/systemd/system/k0sworker.service; systemctl daemon-reload; systemctl restart k0sworker" - -systemctl daemon-reload -systemctl restart k0scontroller -` - // Probably we need to enable the cloud provider plugin in k0s configuration. - // --enable-cloud-provider on worker nodes systemd file /etc/systemd/system/k0sworker.service - // in addition on the first node: /etc/systemd/system/k0scontroller.service the flag --enable-cloud-provider - - err := b.fw.WriteFile("configure-k0s.sh", []byte(script), 0755) - if err != nil { - return fmt.Errorf("failed to write configure-k0s.sh: %w", err) - } - err = b.Env.ControlPlaneNodes[0].NodeClient.CopyFile(b.Env.ControlPlaneNodes[0], "configure-k0s.sh", "/root/configure-k0s.sh") - if err != nil { - return fmt.Errorf("failed to copy configure-k0s.sh to control plane node: %w", err) - } - err = b.Env.ControlPlaneNodes[0].RunSSHCommand("root", "chmod +x /root/configure-k0s.sh") - if err != nil { - return fmt.Errorf("failed to make configure-k0s.sh executable on control plane node: %w", err) - } - return nil -} - -func (b *GCPBootstrapper) RunK0sConfigScript() error { - err := b.Env.ControlPlaneNodes[0].RunSSHCommand("root", "/root/configure-k0s.sh") - if err != nil { - return fmt.Errorf("failed to install Codesphere from jumpbox: %w", err) - } - - return nil -} diff --git a/internal/bootstrap/gcp/gcp_client.go b/internal/bootstrap/gcp/gcp_client.go index 7b764de32..4e41c6ebc 100644 --- a/internal/bootstrap/gcp/gcp_client.go +++ b/internal/bootstrap/gcp/gcp_client.go @@ -25,9 +25,11 @@ import ( "github.com/lithammer/shortuuid" "google.golang.org/api/cloudbilling/v1" "google.golang.org/api/dns/v1" + "google.golang.org/api/googleapi" "google.golang.org/api/iam/v1" "google.golang.org/api/iterator" publicca "google.golang.org/api/publicca/v1" + storage "google.golang.org/api/storage/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/fieldmaskpb" @@ -63,6 +65,8 @@ type GCPClientManager interface { EnsureDNSRecordSets(projectID, zoneName string, records []*dns.ResourceRecordSet) error DeleteDNSRecordSets(projectID, zoneName, baseDomain string) error CreatePublicCAExternalAccountKey(projectID string) (keyID, b64MacKey string, err error) + EnsureStorageBucket(projectID, bucketName, location string) error + CreateHMACKey(projectID, serviceAccountEmail string) (accessID, secret string, err error) } // Concrete implementation @@ -101,6 +105,7 @@ func (c *GCPClient) GetProjectByName(folderID string, displayName string) (*reso // No more results found return nil, fmt.Errorf("project not found: %s", displayName) } + if err != nil { return nil, fmt.Errorf("error iterating projects: %w", err) } @@ -231,10 +236,12 @@ func (c *GCPClient) GetBillingInfo(projectID string) (*cloudbilling.ProjectBilli } projectName := getProjectResourceName(projectID) + billingInfo, err := billingService.Projects.GetBillingInfo(projectName).Do() if err != nil { return nil, err } + return billingInfo, nil } @@ -250,6 +257,7 @@ func (c *GCPClient) EnableBilling(projectID, billingAccount string) error { BillingAccountName: fmt.Sprintf("billingAccounts/%s", billingAccount), } _, err = billingService.Projects.UpdateBillingInfo(projectName, billingInfo).Context(c.ctx).Do() + return err } @@ -262,13 +270,16 @@ func (c *GCPClient) EnableAPIs(projectID string, apis []string) error { defer util.IgnoreError(client.Close) // enable APIs in parallel wg := sync.WaitGroup{} + errCh := make(chan error, len(apis)) for _, api := range apis { serviceName := fmt.Sprintf("projects/%s/services/%s", projectID, api) + wg.Add(1) go func(serviceName, api string) { defer wg.Done() + c.st.Logf("Enabling API %s", api) op, err := client.EnableService(c.ctx, &serviceusagepb.EnableServiceRequest{Name: serviceName}) @@ -276,10 +287,12 @@ func (c *GCPClient) EnableAPIs(projectID string, apis []string) error { c.st.Logf("API %s already enabled", api) return } + if err != nil { errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) return } + if _, err := op.Wait(c.ctx); err != nil { errCh <- fmt.Errorf("failed to enable API %s: %w", api, err) return @@ -291,13 +304,16 @@ func (c *GCPClient) EnableAPIs(projectID string, apis []string) error { wg.Wait() close(errCh) + errStr := "" for err := range errCh { errStr += err.Error() + "; " } + if len(errStr) > 0 { return fmt.Errorf("errors occurred while enabling APIs: %s", errStr) } + return nil } @@ -318,11 +334,14 @@ func (c *GCPClient) CreateArtifactRegistry(projectID, region, repoName string) ( Description: "Codesphere managed registry", }, } + op, err := client.CreateRepository(c.ctx, repoReq) if err != nil && !strings.Contains(err.Error(), "already exists") { return nil, err } + var repo *artifactpb.Repository + if err == nil { _, err = op.Wait(c.ctx) if err != nil { @@ -348,6 +367,7 @@ func (c *GCPClient) GetArtifactRegistry(projectID, region, repoName string) (*ar defer util.IgnoreError(client.Close) fullRepoName := fmt.Sprintf("projects/%s/locations/%s/repositories/%s", projectID, region, repoName) + repo, err := client.GetRepository(c.ctx, &artifactpb.GetRepositoryRequest{ Name: fullRepoName, }) @@ -363,6 +383,7 @@ func (c *GCPClient) GetArtifactRegistry(projectID, region, repoName string) (*ar // and an error if any occurred during the process. func (c *GCPClient) CreateServiceAccount(projectID, name, displayName string) (string, bool, error) { saMail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", name, projectID) + iamService, err := iam.NewService(c.ctx) if err != nil { return saMail, false, err @@ -374,10 +395,12 @@ func (c *GCPClient) CreateServiceAccount(projectID, name, displayName string) (s DisplayName: displayName, }, } + _, err = iamService.Projects.ServiceAccounts.Create(fmt.Sprintf("projects/%s", projectID), saReq).Context(c.ctx).Do() if err != nil && !strings.Contains(err.Error(), "already exists") { return saMail, false, err } + if err != nil && strings.Contains(err.Error(), "already exists") { return saMail, false, nil } @@ -395,6 +418,7 @@ func (c *GCPClient) CreateServiceAccountKey(projectID, saEmail string) (string, keyReq := &iam.CreateServiceAccountKeyRequest{} saName := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail) + key, err := iamService.Projects.ServiceAccounts.Keys.Create(saName, keyReq).Context(c.ctx).Do() if err != nil { return "", err @@ -408,6 +432,7 @@ func (c *GCPClient) AssignIAMRole(projectID, saName string, saProjectID string, saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, saProjectID) member := fmt.Sprintf("serviceAccount:%s", saEmail) resource := fmt.Sprintf("projects/%s", projectID) + return c.addRoleBindingToProject(member, roles, resource) } @@ -429,18 +454,23 @@ func (c *GCPClient) addRoleBindingToProject(member string, roles []string, resou // Add role bindings to policy updated := false + for _, role := range roles { bindingExists := false + for _, binding := range policy.Bindings { if binding.Role == role { if !slices.Contains(binding.Members, member) { binding.Members = append(binding.Members, member) updated = true } + bindingExists = true + break } } + if bindingExists { continue } @@ -462,6 +492,7 @@ func (c *GCPClient) addRoleBindingToProject(member string, roles []string, resou Policy: policy, } _, err = client.SetIamPolicy(c.ctx, setReq) + return err } @@ -470,6 +501,7 @@ func (c *GCPClient) RemoveIAMRoleBinding(projectID, saName string, saProjectID s saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saName, saProjectID) member := fmt.Sprintf("serviceAccount:%s", saEmail) resource := fmt.Sprintf("projects/%s", projectID) + return c.removeRoleBindingFromProject(member, roles, resource) } @@ -486,18 +518,22 @@ func (c *GCPClient) removeRoleBindingFromProject(member string, roles []string, } updated := false + for _, role := range roles { for i, binding := range policy.Bindings { if binding.Role != role { continue } + before := len(binding.Members) + policy.Bindings[i].Members = slices.DeleteFunc(binding.Members, func(m string) bool { return m == member }) if len(policy.Bindings[i].Members) != before { updated = true } + break } } @@ -507,17 +543,20 @@ func (c *GCPClient) removeRoleBindingFromProject(member string, roles []string, } var validBindings []*iampb.Binding + for _, b := range policy.Bindings { if len(b.Members) > 0 { validBindings = append(validBindings, b) } } + policy.Bindings = validBindings _, err = client.SetIamPolicy(c.ctx, &iampb.SetIamPolicyRequest{ Resource: resource, Policy: policy, }) + return err } @@ -534,6 +573,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router Name: &networkName, AutoCreateSubnetworks: protoBool(false), } + op, err := networksClient.Insert(c.ctx, &computepb.InsertNetworkRequest{ Project: projectID, NetworkResource: network, @@ -541,6 +581,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router if err != nil && !strings.Contains(err.Error(), "already exists") { return err } + if err == nil { if err := op.Wait(c.ctx); err != nil { return err @@ -562,6 +603,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router Region: ®ion, Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", projectID, networkName)), } + op, err = subnetsClient.Insert(c.ctx, &computepb.InsertSubnetworkRequest{ Project: projectID, Region: region, @@ -570,6 +612,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router if err != nil && !strings.Contains(err.Error(), "already exists") { return err } + if err == nil { if err := op.Wait(c.ctx); err != nil { return err @@ -590,6 +633,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router Region: ®ion, Network: protoString(fmt.Sprintf("projects/%s/global/networks/%s", projectID, networkName)), } + op, err = routersClient.Insert(c.ctx, &computepb.InsertRouterRequest{ Project: projectID, Region: region, @@ -598,6 +642,7 @@ func (c *GCPClient) CreateVPC(projectID, region, networkName, subnetName, router if err != nil && !IsAlreadyExistsError(err) { return fmt.Errorf("failed to create router: %w", err) } + if err == nil { if err := op.Wait(c.ctx); err != nil { return fmt.Errorf("failed to wait for router creation: %w", err) @@ -731,6 +776,7 @@ func (c *GCPClient) CreateAddress(projectID, region string, address *computepb.A if err != nil { return "", err } + if err = op.Wait(c.ctx); err != nil { return "", err } @@ -783,6 +829,7 @@ func (c *GCPClient) EnsureDNSManagedZone(projectID, zoneName, dnsName, descripti DnsName: dnsName, Description: description, } + _, err = service.ManagedZones.Create(projectID, zone).Context(c.ctx).Do() if err != nil { return fmt.Errorf("failed to create DNS zone: %w", err) @@ -811,6 +858,7 @@ func (c *GCPClient) EnsureDNSRecordSets(projectID, zoneName string, records []*d delChange := &dns.Change{ Deletions: deletions, } + _, err = service.Changes.Create(projectID, zoneName, delChange).Context(c.ctx).Do() if err != nil { return fmt.Errorf("failed to delete existing DNS records: %w", err) @@ -820,6 +868,7 @@ func (c *GCPClient) EnsureDNSRecordSets(projectID, zoneName string, records []*d change := &dns.Change{ Additions: records, } + _, err = service.Changes.Create(projectID, zoneName, change).Context(c.ctx).Do() if err != nil { return fmt.Errorf("failed to create DNS records: %w", err) @@ -836,14 +885,17 @@ func (c *GCPClient) DeleteDNSRecordSets(projectID, zoneName, baseDomain string) } var deletions []*dns.ResourceRecordSet + for _, record := range GetDNSRecordNames(baseDomain) { existing, err := service.ResourceRecordSets.Get(projectID, zoneName, record.Name, record.Rtype).Context(c.ctx).Do() if IsNotFoundError(err) { continue } + if err != nil { return fmt.Errorf("failed to get DNS record %s: %w", record.Name, err) } + deletions = append(deletions, existing) } @@ -854,6 +906,7 @@ func (c *GCPClient) DeleteDNSRecordSets(projectID, zoneName, baseDomain string) if _, err = service.Changes.Create(projectID, zoneName, &dns.Change{Deletions: deletions}).Context(c.ctx).Do(); err != nil { return fmt.Errorf("failed to delete DNS records: %w", err) } + return nil } @@ -866,14 +919,62 @@ func (c *GCPClient) CreatePublicCAExternalAccountKey(projectID string) (string, if err != nil { return "", "", fmt.Errorf("failed to create publicca client: %w", err) } + parent := fmt.Sprintf("projects/%s/locations/global", projectID) + key, err := svc.Projects.Locations.ExternalAccountKeys.Create(parent, &publicca.ExternalAccountKey{}).Context(c.ctx).Do() if err != nil { return "", "", fmt.Errorf("failed to create public CA external account key: %w", err) } + return key.KeyId, key.B64MacKey, nil } +// EnsureStorageBucket creates a Cloud Storage bucket in the given project and +// location. It is idempotent: an already-existing bucket owned by the project is +// treated as success. The bucket lives in the project so it is removed together +// with the project on cleanup. +func (c *GCPClient) EnsureStorageBucket(projectID, bucketName, location string) error { + svc, err := storage.NewService(c.ctx) + if err != nil { + return fmt.Errorf("failed to create storage client: %w", err) + } + + bucket := &storage.Bucket{ + Name: bucketName, + Location: location, + } + _, err = svc.Buckets.Insert(projectID, bucket).Context(c.ctx).Do() + if err != nil { + if apiErr, ok := err.(*googleapi.Error); ok && apiErr.Code == 409 { + // Bucket already exists (owned by this project on re-runs). + return nil + } + return fmt.Errorf("failed to create storage bucket %s: %w", bucketName, err) + } + return nil +} + +// CreateHMACKey creates an HMAC key for the given service account, used for +// S3-compatible access to Cloud Storage. The secret is only returned at creation +// time, so callers must persist it. HMAC keys are removed together with the +// project on cleanup. +func (c *GCPClient) CreateHMACKey(projectID, serviceAccountEmail string) (string, string, error) { + svc, err := storage.NewService(c.ctx) + if err != nil { + return "", "", fmt.Errorf("failed to create storage client: %w", err) + } + + key, err := svc.Projects.HmacKeys.Create(projectID, serviceAccountEmail).Context(c.ctx).Do() + if err != nil { + return "", "", fmt.Errorf("failed to create HMAC key: %w", err) + } + if key.Metadata == nil { + return "", "", fmt.Errorf("HMAC key response missing metadata") + } + return key.Metadata.AccessId, key.Secret, nil +} + // Helper functions func protoString(s string) *string { return &s } func protoBool(b bool) *bool { return &b } diff --git a/internal/bootstrap/gcp/gcp_client_cleanup_test.go b/internal/bootstrap/gcp/gcp_client_cleanup_test.go index 10cf52bfe..5657751bf 100644 --- a/internal/bootstrap/gcp/gcp_client_cleanup_test.go +++ b/internal/bootstrap/gcp/gcp_client_cleanup_test.go @@ -123,6 +123,7 @@ var _ = Describe("GCP Client Cleanup Methods", func() { records := gcp.GetDNSRecordNames(baseDomain) Expect(records).To(HaveLen(5)) + for _, record := range records { Expect(record.Name).To(ContainSubstring("internal.codesphere.com")) Expect(record.Name).To(HaveSuffix(".")) @@ -149,5 +150,4 @@ var _ = Describe("GCP Client Cleanup Methods", func() { }) }) }) - }) diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index 62ba6c8c8..dae799909 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -12,6 +12,7 @@ import ( "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" "cloud.google.com/go/compute/apiv1/computepb" "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/bootstrap/datacenter" "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/github" @@ -50,6 +51,7 @@ var _ = Describe("GCP Bootstrapper", func() { JustBeforeEach(func() { var err error + bs, err = gcp.NewGCPBootstrapper( ctx, e, @@ -166,20 +168,21 @@ var _ = Describe("GCP Bootstrapper", func() { icg.EXPECT().ApplyProfile("minimal").Return(nil) // Returning a real install config to avoid nil pointer dereferences later icg.EXPECT().GetInstallConfig().RunAndReturn(func() *files.RootConfig { - realIcm := installer.NewInstallConfigManager() + realIcm := newPlainInstallConfigManager() _ = realIcm.ApplyProfile("minimal") + return realIcm.GetInstallConfig() }) - projectId := "test-project-12345" + projectID := "test-project-12345" // EnsureSecrets - fw.EXPECT().Exists("fake-secret").Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile("fake-secret").Return(nil) icg.EXPECT().GetVault().Return(&files.InstallVault{}) // EnsureProject gc.EXPECT().GetProjectByName(mock.Anything, "test-project").Return(nil, fmt.Errorf("project not found: test-project")) - gc.EXPECT().CreateProjectID("test-project").Return(projectId) + gc.EXPECT().CreateProjectID("test-project").Return(projectID) gc.EXPECT().CreateProject(mock.Anything, mock.Anything, "test-project", mock.Anything).Return(mock.Anything, nil) // WriteInfraFile @@ -187,55 +190,61 @@ var _ = Describe("GCP Bootstrapper", func() { fw.EXPECT().WriteFile(mock.Anything, mock.Anything, os.FileMode(0644)).Return(nil) // EnsureBilling - gc.EXPECT().GetBillingInfo(projectId).Return(&cloudbilling.ProjectBillingInfo{BillingEnabled: false}, nil) - gc.EXPECT().EnableBilling(projectId, "test-billing-account").Return(nil) + gc.EXPECT().GetBillingInfo(projectID).Return(&cloudbilling.ProjectBillingInfo{BillingEnabled: false}, nil) + gc.EXPECT().EnableBilling(projectID, "test-billing-account").Return(nil) // EnsureAPIsEnabled - gc.EXPECT().EnableAPIs(projectId, mock.Anything).Return(nil) + gc.EXPECT().EnableAPIs(projectID, mock.Anything).Return(nil) // EnsureArtifactRegistry - gc.EXPECT().GetArtifactRegistry(projectId, "us-central1", "codesphere-registry").Return(nil, fmt.Errorf("not found")) - gc.EXPECT().CreateArtifactRegistry(projectId, "us-central1", "codesphere-registry").Return(&artifactregistrypb.Repository{Name: "codesphere-registry"}, nil) + gc.EXPECT().GetArtifactRegistry(projectID, "us-central1", "codesphere-registry").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateArtifactRegistry(projectID, "us-central1", "codesphere-registry").Return(&artifactregistrypb.Repository{Name: "codesphere-registry"}, nil) // EnsureServiceAccounts - gc.EXPECT().CreateServiceAccount(projectId, "cloud-controller", "cloud-controller").Return("cloud-controller@p.iam.gserviceaccount.com", false, nil) - gc.EXPECT().CreateServiceAccount(projectId, "artifact-registry-writer", "artifact-registry-writer").Return("writer@p.iam.gserviceaccount.com", true, nil) - gc.EXPECT().CreateServiceAccountKey(projectId, "writer@p.iam.gserviceaccount.com").Return("fake-key", nil) + gc.EXPECT().CreateServiceAccount(projectID, "cloud-controller", "cloud-controller").Return("cloud-controller@p.iam.gserviceaccount.com", false, nil) + gc.EXPECT().CreateServiceAccount(projectID, "openfga-backup", "openfga-backup").Return("openfga-backup@"+projectID+".iam.gserviceaccount.com", true, nil) + gc.EXPECT().CreateServiceAccount(projectID, "artifact-registry-writer", "artifact-registry-writer").Return("writer@p.iam.gserviceaccount.com", true, nil) + gc.EXPECT().CreateServiceAccountKey(projectID, "writer@p.iam.gserviceaccount.com").Return("fake-key", nil) // EnsureIAMRoles - gc.EXPECT().AssignIAMRole(projectId, "artifact-registry-writer", projectId, []string{"roles/artifactregistry.writer"}).Return(nil) - gc.EXPECT().AssignIAMRole(projectId, "cloud-controller", projectId, []string{"roles/compute.admin"}).Return(nil) - gc.EXPECT().AssignIAMRole(csEnv.DNSProjectID, "cloud-controller", projectId, []string{"roles/dns.admin"}).Return(nil) + gc.EXPECT().AssignIAMRole(projectID, "artifact-registry-writer", projectID, []string{"roles/artifactregistry.writer"}).Return(nil) + gc.EXPECT().AssignIAMRole(projectID, "cloud-controller", projectID, []string{"roles/compute.admin"}).Return(nil) + gc.EXPECT().AssignIAMRole(csEnv.DNSProjectID, "cloud-controller", projectID, []string{"roles/dns.admin"}).Return(nil) + gc.EXPECT().AssignIAMRole(projectID, "openfga-backup", projectID, []string{"roles/storage.objectAdmin"}).Return(nil) + + // EnsureOpenfgaBackupBucket + gc.EXPECT().EnsureStorageBucket(projectID, projectID+"-openfga-backup", "us-central1").Return(nil) + gc.EXPECT().CreateHMACKey(projectID, "openfga-backup@"+projectID+".iam.gserviceaccount.com").Return("fake-access-id", "fake-secret", nil) // EnsureVPC - gc.EXPECT().CreateVPC(projectId, "us-central1", projectId+"-vpc", projectId+"-us-central1-subnet", projectId+"-router", projectId+"-nat-gateway").Return(nil) + gc.EXPECT().CreateVPC(projectID, "us-central1", projectID+"-vpc", projectID+"-us-central1-subnet", projectID+"-router", projectID+"-nat-gateway").Return(nil) // EnsureFirewallRules (5 times) - gc.EXPECT().CreateFirewallRule(projectId, mock.Anything).Return(nil).Times(5) + gc.EXPECT().CreateFirewallRule(projectID, mock.Anything).Return(nil).Times(5) // EnsureComputeInstances ipResp := makeRunningInstance("10.0.0.1", "1.2.3.4") - mockGetInstanceNotFoundThenRunning(gc, projectId, "us-central1-a", ipResp, 8) + mockGetInstanceNotFoundThenRunning(gc, projectID, "us-central1-a", ipResp, 8) fw.EXPECT().ReadFile(mock.Anything).Return([]byte("fake-key"), nil).Times(1) - gc.EXPECT().CreateInstance(projectId, "us-central1-a", mock.Anything).Return(nil).Times(8) + gc.EXPECT().CreateInstance(projectID, "us-central1-a", mock.Anything).Return(nil).Times(8) // EnsureGatewayIPAddresses - gc.EXPECT().GetAddress(projectId, "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) - gc.EXPECT().CreateAddress(projectId, "us-central1", mock.MatchedBy(func(addr *computepb.Address) bool { return *addr.Name == "gateway" })).Return("1.1.1.1", nil) - gc.EXPECT().GetAddress(projectId, "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) - gc.EXPECT().GetAddress(projectId, "us-central1", "public-gateway").Return(nil, fmt.Errorf("not found")) - gc.EXPECT().CreateAddress(projectId, "us-central1", mock.MatchedBy(func(addr *computepb.Address) bool { return *addr.Name == "public-gateway" })).Return("2.2.2.2", nil) - gc.EXPECT().GetAddress(projectId, "us-central1", "public-gateway").Return(&computepb.Address{Address: protoString("2.2.2.2")}, nil) - gc.EXPECT().GetAddress(projectId, "us-central1", "ssh-proxy").Return(nil, fmt.Errorf("not found")) - gc.EXPECT().CreateAddress(projectId, "us-central1", mock.MatchedBy(func(addr *computepb.Address) bool { return *addr.Name == "ssh-proxy" })).Return("3.3.3.3", nil) - gc.EXPECT().GetAddress(projectId, "us-central1", "ssh-proxy").Return(&computepb.Address{Address: protoString("3.3.3.3")}, nil) + gc.EXPECT().GetAddress(projectID, "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress(projectID, "us-central1", mock.MatchedBy(func(addr *computepb.Address) bool { return *addr.Name == "gateway" })).Return("1.1.1.1", nil) + gc.EXPECT().GetAddress(projectID, "us-central1", "gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().GetAddress(projectID, "us-central1", "public-gateway").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress(projectID, "us-central1", mock.MatchedBy(func(addr *computepb.Address) bool { return *addr.Name == "public-gateway" })).Return("2.2.2.2", nil) + gc.EXPECT().GetAddress(projectID, "us-central1", "public-gateway").Return(&computepb.Address{Address: protoString("2.2.2.2")}, nil) + gc.EXPECT().GetAddress(projectID, "us-central1", "ssh-proxy").Return(nil, fmt.Errorf("not found")) + gc.EXPECT().CreateAddress(projectID, "us-central1", mock.MatchedBy(func(addr *computepb.Address) bool { return *addr.Name == "ssh-proxy" })).Return("3.3.3.3", nil) + gc.EXPECT().GetAddress(projectID, "us-central1", "ssh-proxy").Return(&computepb.Address{Address: protoString("3.3.3.3")}, nil) // UpdateInstallConfig icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, "fake-config-file", "/etc/codesphere/config.yaml").Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, "fake-secret", "/etc/codesphere/secrets/prod.vault.yaml").Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) // Enable Root Login nodeClient.EXPECT().WaitReady(mock.Anything, mock.Anything).Return(nil).Return(nil) @@ -257,11 +266,6 @@ var _ = Describe("GCP Bootstrapper", func() { return len(records) == 5 })).Return(nil) - // GenerateK0sConfigScript - fw.EXPECT().WriteFile("configure-k0s.sh", mock.Anything, os.FileMode(0755)).Return(nil) - nodeClient.EXPECT().CopyFile(mock.Anything, "configure-k0s.sh", "/root/configure-k0s.sh").Return(nil) - nodeClient.EXPECT().RunCommand(mock.Anything, "root", "chmod +x /root/configure-k0s.sh").Return(nil) - err := bs.Bootstrap() Expect(err).NotTo(HaveOccurred()) Expect(bs.Env).NotTo(BeNil()) @@ -293,13 +297,12 @@ var _ = Describe("GCP Bootstrapper", func() { Expect(cpNode.GetExternalIP()).To(Equal("1.2.3.4")) Expect(cpNode.GetInternalIP()).To(Equal("10.0.0.1")) } - - Expect(len(bs.Env.InstallConfig.Codesphere.ManagedServices)).To(Equal(5)) }) }) Describe("ValidateInput", func() { var artifacts []portal.Artifact + Context("When GitHub team and org is set", func() { BeforeEach(func() { csEnv.GitHubTeamOrg = "codesphere-cloud" @@ -727,7 +730,7 @@ var _ = Describe("GCP Bootstrapper", func() { }) It("uses existing when config file exists", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(nil) icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) @@ -737,7 +740,6 @@ var _ = Describe("GCP Bootstrapper", func() { It("loads existing vault before existing config for templating", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(nil) icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) @@ -760,7 +762,7 @@ var _ = Describe("GCP Bootstrapper", func() { Describe("Invalid cases", func() { It("returns error when config file exists but fails to load", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(fmt.Errorf("bad format")) err := bs.EnsureInstallConfig() @@ -784,7 +786,6 @@ var _ = Describe("GCP Bootstrapper", func() { Describe("EnsureSecrets", func() { Describe("Valid EnsureSecrets", func() { It("loads existing secrets file", func() { - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().GetVault().Return(&files.InstallVault{}) @@ -793,7 +794,7 @@ var _ = Describe("GCP Bootstrapper", func() { }) It("skips when secrets file missing", func() { - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().GetVault().Return(&files.InstallVault{}) err := bs.EnsureSecrets() @@ -803,7 +804,6 @@ var _ = Describe("GCP Bootstrapper", func() { Describe("Invalid cases", func() { It("returns error when secrets file load fails", func() { - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(fmt.Errorf("load error")) err := bs.EnsureSecrets() @@ -887,6 +887,7 @@ var _ = Describe("GCP Bootstrapper", func() { icg = installer.NewMockInstallConfigManager(GinkgoT()) icg.EXPECT().GetVault().Return(&files.InstallVault{}) + gc = gcp.NewMockGCPClientManager(GinkgoT()) fw = util.NewMockFileIO(GinkgoT()) }) @@ -1235,9 +1236,79 @@ var _ = Describe("GCP Bootstrapper", func() { err := bs.EnsureHostsConfigured() Expect(err).NotTo(HaveOccurred()) }) + + It("creates the directory the installer uploads the age key to on every node", func() { + mkdirs := map[string]int{} + + nodeClient.EXPECT().RunCommand(mock.Anything, "root", mock.Anything). + RunAndReturn(func(n *node.Node, _ string, command string) error { + if command == "mkdir -p /etc/codesphere/secrets" { + mkdirs[n.GetName()]++ + } + + return nil + }) + + Expect(bs.EnsureHostsConfigured()).To(Succeed()) + // The postgres node plus every cluster node of every data center. + Expect(mkdirs).To(Equal(map[string]int{ + "postgres": 1, + "k0s-1": 1, "k0s-2": 1, "k0s-3": 1, + "ceph-1": 1, "ceph-2": 1, "ceph-3": 1, + })) + }) + + It("creates a secondary data center's own secrets directory on its nodes only", func() { + secondary := &datacenter.DataCenter{ID: 2, Suffix: "-dc2", SecretsDir: "/etc/codesphere/secrets-dc2"} + secondary.ControlPlaneNodes = []*node.Node{fakeNode("k0s-1-dc2", nodeClient)} + secondary.CephNodes = []*node.Node{fakeNode("ceph-1-dc2", nodeClient)} + + bs.Env.DataCenters = []*datacenter.DataCenter{ + { + ID: 1, + SecretsDir: "/etc/codesphere/secrets", + ControlPlaneNodes: bs.Env.ControlPlaneNodes, + CephNodes: bs.Env.CephNodes, + }, + secondary, + } + + mkdirs := map[string][]string{} + + nodeClient.EXPECT().RunCommand(mock.Anything, "root", mock.Anything). + RunAndReturn(func(n *node.Node, _ string, command string) error { + if dir, found := strings.CutPrefix(command, "mkdir -p "); found { + mkdirs[n.GetName()] = append(mkdirs[n.GetName()], dir) + } + + return nil + }) + + Expect(bs.EnsureHostsConfigured()).To(Succeed()) + // Both paths on the secondary's nodes, because the installer's fixed path is + // created everywhere; the secondary's path on nobody else's. + Expect(mkdirs["k0s-1-dc2"]).To(ConsistOf("/etc/codesphere/secrets", "/etc/codesphere/secrets-dc2")) + Expect(mkdirs["ceph-1-dc2"]).To(ConsistOf("/etc/codesphere/secrets", "/etc/codesphere/secrets-dc2")) + Expect(mkdirs["k0s-1"]).To(ConsistOf("/etc/codesphere/secrets")) + Expect(mkdirs["postgres"]).To(ConsistOf("/etc/codesphere/secrets")) + }) }) Describe("Invalid cases", func() { + It("fails when the secrets directory cannot be created", func() { + nodeClient.EXPECT().RunCommand(mock.Anything, "root", mock.Anything). + RunAndReturn(func(_ *node.Node, _ string, command string) error { + if command == "mkdir -p /etc/codesphere/secrets" { + return fmt.Errorf("ouch") + } + + return nil + }) + + err := bs.EnsureHostsConfigured() + Expect(err).To(MatchError(ContainSubstring("failed to create secrets directory on postgres"))) + }) + It("fails when ConfigureInotifyWatches fails", func() { nodeClient.EXPECT().RunCommand(mock.Anything, "root", mock.Anything).Return(fmt.Errorf("ouch")) @@ -1380,6 +1451,7 @@ var _ = Describe("GCP Bootstrapper", func() { BeforeEach(func() { csEnv.InstallVersion = "v1.2.3" csEnv.InstallHash = "abc1234567890" + icg.EXPECT().GetSecretFilePath().Return("/etc/codesphere/secrets/prod.vault.yaml").Maybe() }) Describe("Valid InstallCodesphere", func() { diff --git a/internal/bootstrap/gcp/iam_admin.go b/internal/bootstrap/gcp/iam_admin.go index 4e16e51e6..1e1a7306f 100644 --- a/internal/bootstrap/gcp/iam_admin.go +++ b/internal/bootstrap/gcp/iam_admin.go @@ -145,6 +145,7 @@ func (b *GCPBootstrapper) EnsureBilling() error { if err != nil { return fmt.Errorf("failed to get billing info: %w", err) } + if bi.BillingEnabled && bi.BillingAccountName == b.Env.BillingAccount { return nil } @@ -165,6 +166,7 @@ func (b *GCPBootstrapper) EnsureAPIsEnabled() error { "serviceusage.googleapis.com", "artifactregistry.googleapis.com", "dns.googleapis.com", + "storage.googleapis.com", } if b.Env.GoogleACMEIssuer { apis = append(apis, "publicca.googleapis.com") @@ -185,6 +187,12 @@ func (b *GCPBootstrapper) EnsureServiceAccounts() error { return err } + // Dedicated service account for OpenFGA database backups. Its storage role is + // assigned in EnsureIAMRoles and its HMAC key created in EnsureOpenfgaBackupBucket. + if _, _, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, openfgaBackupSAName, openfgaBackupSAName); err != nil { + return fmt.Errorf("failed to ensure openfga backup service account: %w", err) + } + if b.Env.RegistryType == RegistryTypeArtifactRegistry { sa, newSa, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, "artifact-registry-writer", "artifact-registry-writer") if err != nil { @@ -195,6 +203,7 @@ func (b *GCPBootstrapper) EnsureServiceAccounts() error { if s := b.icg.GetVault().GetSecret(files.SecretRegistryPassword); s != nil && s.Fields != nil { existingRegPwd = s.Fields.Password } + if !newSa && existingRegPwd != "" { return nil } @@ -206,8 +215,10 @@ func (b *GCPBootstrapper) EnsureServiceAccounts() error { if retries > 3 { return fmt.Errorf("failed to create service account key: %w", err) } + b.stlog.LogRetry() b.Time.Sleep(5 * time.Second) + continue } @@ -233,6 +244,11 @@ func (b *GCPBootstrapper) EnsureIAMRoles() error { return fmt.Errorf("failed to ensure DNS permissions: %w", err) } + err = b.ensureIAMRoleWithRetry(b.Env.ProjectID, openfgaBackupSAName, b.Env.ProjectID, []string{"roles/storage.objectAdmin"}) + if err != nil { + return fmt.Errorf("failed to ensure openfga backup role bindings: %w", err) + } + if b.Env.RegistryType != RegistryTypeArtifactRegistry { return nil } diff --git a/internal/bootstrap/gcp/iam_admin_test.go b/internal/bootstrap/gcp/iam_admin_test.go index ee37a8463..44c3fc706 100644 --- a/internal/bootstrap/gcp/iam_admin_test.go +++ b/internal/bootstrap/gcp/iam_admin_test.go @@ -43,6 +43,7 @@ var _ = Describe("IAM & Admin", func() { JustBeforeEach(func() { var err error + bs, err = gcp.NewGCPBootstrapper( ctx, e, @@ -130,6 +131,7 @@ var _ = Describe("IAM & Admin", func() { Describe("Invalid cases", func() { It("returns error when GetProjectByName fails unexpectedly", func() { gc.EXPECT().GetProjectByName("", csEnv.ProjectName).Return(nil, fmt.Errorf("api error")) + err := bs.EnsureProject() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get project")) @@ -166,6 +168,7 @@ var _ = Describe("IAM & Admin", func() { BillingAccountName: csEnv.BillingAccount, } gc.EXPECT().GetBillingInfo(csEnv.ProjectID).Return(bi, nil) + err := bs.EnsureBilling() Expect(err).NotTo(HaveOccurred()) }) @@ -215,6 +218,7 @@ var _ = Describe("IAM & Admin", func() { "serviceusage.googleapis.com", "artifactregistry.googleapis.com", "dns.googleapis.com", + "storage.googleapis.com", }).Return(nil) err := bs.EnsureAPIsEnabled() @@ -242,6 +246,7 @@ var _ = Describe("IAM & Admin", func() { }) It("creates cloud-controller and skips writer", func() { gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "cloud-controller", "cloud-controller").Return("email@sa", false, nil) + gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "openfga-backup", "openfga-backup").Return("openfga-backup@sa", true, nil) err := bs.EnsureServiceAccounts() Expect(err).NotTo(HaveOccurred()) @@ -258,8 +263,10 @@ var _ = Describe("IAM & Admin", func() { icg.EXPECT().GetVault().Return(vault) gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "cloud-controller", "cloud-controller").Return("email@sa", false, nil) + gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "openfga-backup", "openfga-backup").Return("openfga-backup@sa", true, nil) gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "artifact-registry-writer", "artifact-registry-writer").Return("writer@sa", true, nil) gc.EXPECT().CreateServiceAccountKey(csEnv.ProjectID, "writer@sa").Return("key-content", nil) + err := bs.EnsureServiceAccounts() Expect(err).NotTo(HaveOccurred()) Expect(vault.GetSecret(files.SecretRegistryPassword).Fields.Password).To(Equal("key-content")) @@ -286,6 +293,7 @@ var _ = Describe("IAM & Admin", func() { It("assigns roles correctly", func() { gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "cloud-controller", csEnv.ProjectID, []string{"roles/compute.admin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.DNSProjectID, "cloud-controller", csEnv.ProjectID, []string{"roles/dns.admin"}).Return(nil) + gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "openfga-backup", csEnv.ProjectID, []string{"roles/storage.objectAdmin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "artifact-registry-writer", csEnv.ProjectID, []string{"roles/artifactregistry.writer"}).Return(nil) err := bs.EnsureIAMRoles() @@ -299,6 +307,7 @@ var _ = Describe("IAM & Admin", func() { It("assigns DNS role to cloud-controller in main project", func() { gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "cloud-controller", csEnv.ProjectID, []string{"roles/compute.admin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "cloud-controller", csEnv.ProjectID, []string{"roles/dns.admin"}).Return(nil) + gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "openfga-backup", csEnv.ProjectID, []string{"roles/storage.objectAdmin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "artifact-registry-writer", csEnv.ProjectID, []string{"roles/artifactregistry.writer"}).Return(nil) err := bs.EnsureIAMRoles() @@ -317,5 +326,4 @@ var _ = Describe("IAM & Admin", func() { }) }) }) - }) diff --git a/internal/bootstrap/gcp/iam_admin_unexported_test.go b/internal/bootstrap/gcp/iam_admin_unexported_test.go index 49f810d4c..b191a60c3 100644 --- a/internal/bootstrap/gcp/iam_admin_unexported_test.go +++ b/internal/bootstrap/gcp/iam_admin_unexported_test.go @@ -12,8 +12,10 @@ import ( var _ = Describe("IAM & Admin - Unexported", func() { Describe("calculateProjectExpiryLabel", func() { - const customDateFormat string = "2006-01-02_15-04-05_utc" - const customDateFormatRegex string = `^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_utc$` + const ( + customDateFormat string = "2006-01-02_15-04-05_utc" + customDateFormatRegex string = `^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_utc$` + ) type validTestCase struct { inputTTL string diff --git a/internal/bootstrap/gcp/infrafile.go b/internal/bootstrap/gcp/infrafile.go index 72378f9f8..807a9c541 100644 --- a/internal/bootstrap/gcp/infrafile.go +++ b/internal/bootstrap/gcp/infrafile.go @@ -40,18 +40,28 @@ func LoadInfraFile(fw util.FileIO, infraFilePath string) (CodesphereEnvironment, // WriteInfraFile writes details about the bootstrapped codesphere environment into a file. func (b *GCPBootstrapper) WriteInfraFile() error { + if err := b.ensureDataCenters(); err != nil { + return err + } + + // The steps that still write the top-level node and IP fields are migrated to DataCenters + // one by one, so keep both in sync until the last one is. + b.mirrorPrimaryDataCenter() + envBytes, err := json.MarshalIndent(b.Env, "", " ") if err != nil { return fmt.Errorf("failed to marshal codesphere env: %w", err) } workdir := env.NewEnv().GetOmsWorkdir() + err = b.fw.MkdirAll(workdir, 0755) if err != nil { return fmt.Errorf("failed to create workdir: %w", err) } infraFilePath := GetInfraFilePath() + err = b.fw.WriteFile(infraFilePath, envBytes, 0644) if err != nil { return fmt.Errorf("failed to write gcp bootstrap env file: %w", err) diff --git a/internal/bootstrap/gcp/infrafile_test.go b/internal/bootstrap/gcp/infrafile_test.go index d633bb7fe..5ff905257 100644 --- a/internal/bootstrap/gcp/infrafile_test.go +++ b/internal/bootstrap/gcp/infrafile_test.go @@ -39,6 +39,7 @@ var _ = Describe("Infrafile", func() { JustBeforeEach(func() { var err error + bs, err = gcp.NewGCPBootstrapper( ctx, e, diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 774dbdc0c..a3aee315f 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -51,11 +51,6 @@ func (b *GCPBootstrapper) EnsureInstallConfig() error { } func (b *GCPBootstrapper) loadVaultForConfigTemplating() error { - if !b.fw.Exists(b.Env.SecretsFilePath) { - return nil - } - - // during bootstrapping, the vault is not yet encrpyted if err := b.icg.LoadVaultFromUnecryptedFile(b.Env.SecretsFilePath); err != nil { return fmt.Errorf("failed to load vault from file: %w", err) } @@ -71,12 +66,14 @@ func (b *GCPBootstrapper) recoverConfig() error { if err != nil { return fmt.Errorf("failed to find gcp project for config recovery: %w", err) } + b.Env.ProjectID = existingProject.ProjectId jumpbox, err := b.GetNodeByName("jumpbox") if err != nil { return fmt.Errorf("failed to find jumpbox node for config recovery: %w", err) } + b.Env.Jumpbox = jumpbox err = b.Env.Jumpbox.NodeClient.DownloadFile(jumpbox, remoteInstallConfigPath, b.Env.InstallConfigPath) @@ -121,9 +118,11 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if b.Env.DatacenterName == "" { b.Env.DatacenterName = "dev" } + b.Env.InstallConfig.Datacenter.Name = b.Env.DatacenterName b.Env.InstallConfig.Datacenter.City = "Karlsruhe" b.Env.InstallConfig.Datacenter.CountryCode = "DE" + b.Env.InstallConfig.Secrets.BaseDir = b.Env.SecretsDir if b.Env.RegistryType != RegistryTypeGitHub { b.Env.InstallConfig.Registry.ReplaceImagesInBom = true @@ -210,12 +209,14 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { "cloud.google.com/load-balancer-ipv4": b.Env.PublicGatewayIP, } - b.applySshProxyConfig() + b.applyPcAppsDefaults() + b.applyManagedServiceDefaults() dnsProject := b.Env.DNSProjectID if b.Env.DNSProjectID == "" { dnsProject = b.Env.ProjectID } + b.Env.InstallConfig.Cluster.Certificates.Override = map[string]interface{}{ "issuers": map[string]interface{}{ "letsEncryptHttp": map[string]interface{}{ @@ -232,10 +233,12 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { }, }, } + acmeServer := "https://acme-v02.api.letsencrypt.org/directory" if b.Env.ACMEStaging { acmeServer = "https://acme-staging-v02.api.letsencrypt.org/directory" } + acmeConfig := &files.ACMEConfig{ Enabled: true, Email: "oms-testing@" + b.Env.BaseDomain, @@ -246,10 +249,13 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if err != nil { return fmt.Errorf("failed to obtain Google Public CA EAB credentials: %w", err) } + acmeConfig.Server = "https://dv.acme-v02.api.pki.goog/directory" acmeConfig.EABKeyID = keyID + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretAcmeEabMacKey, Fields: &files.SecretFields{Password: b64MacKey}}) } + b.Env.InstallConfig.Codesphere.CertIssuer = &files.CertIssuerConfig{ Type: "acme", Acme: acmeConfig, @@ -285,6 +291,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretGithubAppsClientId, Fields: &files.SecretFields{Password: b.Env.GitHubAppClientID}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretGithubAppsClientSecret, Fields: &files.SecretFields{Password: b.Env.GitHubAppClientSecret}}) } + if b.Env.GitLabAppClientID != "" && b.Env.GitLabAppClientSecret != "" { b.Env.InstallConfig.Codesphere.GitProviders.GitLab = &files.GitProviderConfig{ Enabled: true, @@ -303,6 +310,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretGitlabAppClientId, Fields: &files.SecretFields{Password: b.Env.GitLabAppClientID}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretGitlabAppClientSecret, Fields: &files.SecretFields{Password: b.Env.GitLabAppClientSecret}}) } + if b.Env.BitbucketAppClientID != "" && b.Env.BitbucketAppClientSecret != "" { b.Env.InstallConfig.Codesphere.GitProviders.Bitbucket = &files.GitProviderConfig{ Enabled: true, @@ -321,6 +329,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretBitbucketAppsClientId, Fields: &files.SecretFields{Password: b.Env.BitbucketAppClientID}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretBitbucketAppsClientSecret, Fields: &files.SecretFields{Password: b.Env.BitbucketAppClientSecret}}) } + if b.Env.AzureDevOpsAppClientID != "" && b.Env.AzureDevOpsAppClientSecret != "" { b.Env.InstallConfig.Codesphere.GitProviders.AzureDevOps = &files.GitProviderConfig{ Enabled: true, @@ -340,11 +349,13 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretAzureDevOpsAppClientId, Fields: &files.SecretFields{Password: b.Env.AzureDevOpsAppClientID}}) b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretAzureDevOpsAppClientSecret, Fields: &files.SecretFields{Password: b.Env.AzureDevOpsAppClientSecret}}) } + if b.Env.OidcIssuerURL != "" && b.Env.OidcClientID != "" && b.Env.OidcClientSecret != "" { name := b.Env.OidcProviderName if name == "" { name = "OIDC" } + b.Env.InstallConfig.Codesphere.OAuth = &files.OAuthProvidersConfig{ Oidc: &files.OidcOAuthProvider{ Type: "oidc", @@ -375,8 +386,10 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if b.Env.ClusterAdminEmail != "" { b.Env.InstallConfig.Codesphere.ClusterAdminEmail = b.Env.ClusterAdminEmail } + b.applyExternalLokiConfig() b.applyPrometheusRemoteWriteConfig() + b.applyOpenfgaBackupConfig() // Secret generation is idempotent and also backfills secrets introduced // after an existing vault was created (for example the auth keys required by @@ -395,6 +408,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if b.Env.InstallConfig.Cluster.Monitoring == nil { b.Env.InstallConfig.Cluster.Monitoring = &files.MonitoringConfig{} } + b.Env.InstallConfig.Cluster.Monitoring.CentralOtelExport = &files.CentralOtelConfig{ Enabled: true, Username: b.Env.CentralOtelUsername, @@ -416,7 +430,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { return fmt.Errorf("failed to write config file: %w", err) } - if err := b.icg.WriteUnencryptedVault(b.Env.SecretsFilePath, true); err != nil { + if err := b.icg.WriteVault(b.Env.SecretsFilePath, true); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } @@ -433,7 +447,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { return nil } -func (b *GCPBootstrapper) applySshProxyConfig() { +func (b *GCPBootstrapper) applyPcAppsDefaults() { b.Env.InstallConfig.PcApps = util.DeepMergeMaps(b.Env.InstallConfig.PcApps, files.ChartValues{ "applications": map[string]any{ "ssh-workspace-proxy": map[string]any{ @@ -449,10 +463,114 @@ func (b *GCPBootstrapper) applySshProxyConfig() { }, }, }, + "rabbitmq-operator": map[string]any{ + "enabled": true, + }, + "ms-backend-k8s": map[string]any{ + "enabled": true, + }, + "ms-backend-opensearch": map[string]any{ + "enabled": true, + }, + // KubeVirt and CDI are needed for VM image import/DataVolumes; the + // pc-applications chart ships both disabled by default. + "kubevirt-operator": map[string]any{ + "enabled": true, + }, + "kubevirt-cr": map[string]any{ + "enabled": true, + }, + "cdi-operator": map[string]any{ + "enabled": true, + }, + "cdi-cr": map[string]any{ + "enabled": true, + }, }, }) } +func (b *GCPBootstrapper) applyManagedServiceDefaults() { + if b.Env.InstallConfig.Codesphere.ManagedServices == nil { + ms := []files.ManagedServiceConfig{ + {Name: "postgres", Version: "v1"}, + {Name: "babelfish", Version: "v1"}, + {Name: "s3", Version: "v1"}, + {Name: "virtual-k8s", Version: "v1"}, + {Name: "ferretdb", Version: "v0"}, + {Name: "opensearch", Version: "v0"}, + {Name: "valkey", Version: "v0"}, + {Name: "rabbitmq", Version: "v0"}, + } + + if util.InstallVersionAtLeast(b.Env.InstallVersion, "v1.106.0") { + ms = append(ms, files.ManagedServiceConfig{Name: "url-shortener", Version: "v0"}) + } + + b.Env.InstallConfig.Codesphere.ManagedServices = ms + } +} + +// openfgaBackupSAName is the service account whose HMAC key authenticates OpenFGA +// database backups against the S3-compatible Cloud Storage endpoint. +const openfgaBackupSAName = "openfga-backup" + +// EnsureOpenfgaBackupBucket creates the Cloud Storage bucket and HMAC key used for +// OpenFGA database backups. The bucket and HMAC key live in the project so they are +// removed together with the project on cleanup. The dedicated service account and +// its storage role are provisioned by EnsureServiceAccounts / EnsureIAMRoles. +// +// The HMAC secret is only returned at creation time, so it is persisted to the +// vault by applyOpenfgaBackupConfig. Creation is skipped when a real secret is +// already present in the vault (e.g. on re-runs or recovered configs). +func (b *GCPBootstrapper) EnsureOpenfgaBackupBucket() error { + bucketName := fmt.Sprintf("%s-openfga-backup", b.Env.ProjectID) + + if err := b.GCPClient.EnsureStorageBucket(b.Env.ProjectID, bucketName, b.Env.Region); err != nil { + return fmt.Errorf("failed to ensure openfga backup bucket: %w", err) + } + b.Env.OpenfgaBackupBucket = bucketName + + // The HMAC secret cannot be retrieved after creation, so only create a new key + // when we don't already have a real one persisted in the vault. + if existing := b.icg.GetVault().GetSecret(files.SecretOpenfgaDbBackupSecretAccessKey); existing != nil && + existing.Fields != nil && existing.Fields.Password != "" && existing.Fields.Password != "dummy" { + return nil + } + + saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", openfgaBackupSAName, b.Env.ProjectID) + accessID, secret, err := b.GCPClient.CreateHMACKey(b.Env.ProjectID, saEmail) + if err != nil { + return fmt.Errorf("failed to create openfga backup HMAC key: %w", err) + } + b.Env.OpenfgaBackupAccessKeyID = accessID + b.Env.OpenfgaBackupSecret = secret + + return nil +} + +// applyOpenfgaBackupConfig wires the bucket created by EnsureOpenfgaBackupBucket +// into the install config and persists the HMAC credentials to the vault. It is a +// no-op when no bucket was provisioned. +func (b *GCPBootstrapper) applyOpenfgaBackupConfig() { + if b.Env.OpenfgaBackupBucket == "" { + return + } + + b.Env.InstallConfig.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{ + Enabled: true, + DestinationPath: "s3://" + b.Env.OpenfgaBackupBucket, + EndpointURL: "https://storage.googleapis.com", + } + + // Only overwrite when a new HMAC key was created this run; otherwise the + // existing secret loaded from the vault is kept. + if b.Env.OpenfgaBackupAccessKeyID != "" { + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretOpenfgaDbBackupAccessKeyId, Fields: &files.SecretFields{Password: b.Env.OpenfgaBackupAccessKeyID}}) + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretOpenfgaDbBackupSecretAccessKey, Fields: &files.SecretFields{Password: b.Env.OpenfgaBackupSecret}}) + } +} + func (b *GCPBootstrapper) applyExternalLokiConfig() { if b.Env.ExternalLokiEndpoint == "" { return @@ -461,6 +579,7 @@ func (b *GCPBootstrapper) applyExternalLokiConfig() { if b.Env.InstallConfig.Cluster.Monitoring == nil { b.Env.InstallConfig.Cluster.Monitoring = &files.MonitoringConfig{} } + if b.Env.InstallConfig.Cluster.Monitoring.GrafanaAlloy == nil { b.Env.InstallConfig.Cluster.Monitoring.GrafanaAlloy = &files.GrafanaAlloyConfig{} } @@ -484,9 +603,11 @@ func (b *GCPBootstrapper) applyPrometheusRemoteWriteConfig() { if b.Env.InstallConfig.Cluster.Monitoring == nil { b.Env.InstallConfig.Cluster.Monitoring = &files.MonitoringConfig{} } + if b.Env.InstallConfig.Cluster.Monitoring.Prometheus == nil { b.Env.InstallConfig.Cluster.Monitoring.Prometheus = &files.PrometheusConfig{} } + if b.Env.InstallConfig.Cluster.Monitoring.Prometheus.RemoteWrite == nil { b.Env.InstallConfig.Cluster.Monitoring.Prometheus.RemoteWrite = &files.RemoteWriteConfig{} } @@ -513,6 +634,7 @@ func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPri if caSecret == nil || caSecret.File == nil { return fmt.Errorf("postgres CA key not found in vault") } + primaryKeyPEM, primaryCertPEM, err := secrets.GenerateServerCertificate( caSecret.File.Content, b.Env.InstallConfig.Postgres.CACertPem, @@ -521,12 +643,16 @@ func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPri if err != nil { return fmt.Errorf("failed to generate primary server certificate: %w", err) } + if err := secrets.ValidateCertKeyPair(primaryCertPEM, primaryKeyPEM); err != nil { return fmt.Errorf("primary PostgreSQL cert/key validation failed: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresPrimaryServerKeyPem, File: &files.SecretFile{Name: "primary.key", Content: primaryKeyPEM}}) + b.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem = primaryCertPEM } + if b.Env.InstallConfig.Postgres.Replica != nil { replicaKeySecret := vault.GetSecret(files.SecretPostgresReplicaServerKeyPem) if replicaKeySecret == nil || replicaKeySecret.File == nil { @@ -534,6 +660,7 @@ func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPri if caSecret == nil || caSecret.File == nil { return fmt.Errorf("postgres CA key not found in vault") } + replicaKeyPEM, replicaCertPEM, err := secrets.GenerateServerCertificate( caSecret.File.Content, b.Env.InstallConfig.Postgres.CACertPem, @@ -542,13 +669,17 @@ func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPri if err != nil { return fmt.Errorf("failed to generate replica server certificate: %w", err) } + if err := secrets.ValidateCertKeyPair(replicaCertPEM, replicaKeyPEM); err != nil { return fmt.Errorf("replica PostgreSQL cert/key validation failed: %w", err) } + vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresReplicaServerKeyPem, File: &files.SecretFile{Name: "replica.key", Content: replicaKeyPEM}}) + b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem = replicaCertPEM } } + return nil } @@ -567,13 +698,12 @@ func (b *GCPBootstrapper) EnsureAgeKey() error { } func (b *GCPBootstrapper) EnsureSecrets() error { - if b.fw.Exists(b.Env.SecretsFilePath) { - err := b.icg.LoadVaultFromUnecryptedFile(b.Env.SecretsFilePath) - if err != nil { - return fmt.Errorf("failed to load vault file: %w", err) - } + if err := b.icg.LoadVaultFromUnecryptedFile(b.Env.SecretsFilePath); err != nil { + return fmt.Errorf("failed to load vault file: %w", err) } + b.Env.Secrets = b.icg.GetVault() + return nil } diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index 383989631..4663793ba 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -45,6 +45,7 @@ var _ = Describe("Installconfig & Secrets", func() { JustBeforeEach(func() { var err error + bs, err = gcp.NewGCPBootstrapper( ctx, e, @@ -115,7 +116,7 @@ var _ = Describe("Installconfig & Secrets", func() { }) It("uses existing when config file exists", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(nil) icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) @@ -125,7 +126,6 @@ var _ = Describe("Installconfig & Secrets", func() { It("loads existing vault before existing config for templating", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(nil) icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) @@ -158,7 +158,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("overwrites an existing config", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(nil) icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) @@ -171,7 +171,7 @@ var _ = Describe("Installconfig & Secrets", func() { Describe("Invalid cases", func() { It("returns error when config file exists but fails to load", func() { fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(fmt.Errorf("bad format")) err := bs.EnsureInstallConfig() @@ -266,7 +266,7 @@ var _ = Describe("Installconfig & Secrets", func() { nodeClient.EXPECT().RunCommand(mock.Anything, mock.Anything, mock.Anything).Return(nil) fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(fmt.Errorf("bad format")) err := bs.EnsureInstallConfig() @@ -281,7 +281,6 @@ var _ = Describe("Installconfig & Secrets", func() { Describe("EnsureSecrets", func() { Describe("Valid EnsureSecrets", func() { It("loads existing secrets file", func() { - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().GetVault().Return(&files.InstallVault{}) @@ -290,7 +289,7 @@ var _ = Describe("Installconfig & Secrets", func() { }) It("skips when secrets file missing", func() { - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(nil) icg.EXPECT().GetVault().Return(&files.InstallVault{}) err := bs.EnsureSecrets() @@ -300,7 +299,6 @@ var _ = Describe("Installconfig & Secrets", func() { Describe("Invalid cases", func() { It("returns error when secrets file load fails", func() { - fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) icg.EXPECT().LoadVaultFromUnecryptedFile(csEnv.SecretsFilePath).Return(fmt.Errorf("load error")) err := bs.EnsureSecrets() @@ -311,11 +309,54 @@ var _ = Describe("Installconfig & Secrets", func() { }) }) + Describe("EnsureOpenfgaBackupBucket", func() { + It("creates the bucket and HMAC key", func() { + vault := &files.InstallVault{} + icg.EXPECT().GetVault().Return(vault) + + gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(nil) + gc.EXPECT().CreateHMACKey("pid", "openfga-backup@pid.iam.gserviceaccount.com").Return("access-id", "secret-key", nil) + + err := bs.EnsureOpenfgaBackupBucket() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.OpenfgaBackupBucket).To(Equal("pid-openfga-backup")) + Expect(bs.Env.OpenfgaBackupAccessKeyID).To(Equal("access-id")) + Expect(bs.Env.OpenfgaBackupSecret).To(Equal("secret-key")) + }) + + It("does not create a new HMAC key when a real secret already exists", func() { + vault := &files.InstallVault{ + Secrets: []files.SecretEntry{ + {Name: files.SecretOpenfgaDbBackupSecretAccessKey, Fields: &files.SecretFields{Password: "existing-secret"}}, + }, + } + icg.EXPECT().GetVault().Return(vault) + + gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(nil) + // CreateHMACKey must not be called. + + err := bs.EnsureOpenfgaBackupBucket() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.OpenfgaBackupBucket).To(Equal("pid-openfga-backup")) + Expect(bs.Env.OpenfgaBackupAccessKeyID).To(BeEmpty()) + }) + + It("returns an error when bucket creation fails", func() { + gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(fmt.Errorf("bucket error")) + + err := bs.EnsureOpenfgaBackupBucket() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to ensure openfga backup bucket")) + }) + }) + Describe("UpdateInstallConfig", func() { var vault *files.InstallVault + BeforeEach(func() { vault = &files.InstallVault{} icg.EXPECT().GetVault().Return(vault).Maybe() + csEnv.GitHubAppName = "fake-app-name" }) Describe("Valid UpdateInstallConfig", func() { @@ -324,13 +365,16 @@ var _ = Describe("Installconfig & Secrets", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() Expect(err).NotTo(HaveOccurred()) + // No openfga backup bucket provisioned → config stays unset. + Expect(bs.Env.InstallConfig.Codesphere.OpenfgaBackups).To(BeNil()) + applications := bs.Env.InstallConfig.PcApps["applications"].(map[string]interface{}) sshProxy := applications["ssh-workspace-proxy"].(map[string]interface{}) Expect(sshProxy["enabled"]).To(Equal(true)) @@ -342,6 +386,11 @@ var _ = Describe("Installconfig & Secrets", func() { sshProxyAnnotations := sshProxyService["annotations"].(map[string]interface{}) Expect(sshProxyAnnotations["cloud.google.com/load-balancer-ipv4"]).To(Equal("3.3.3.3")) + Expect(applications["kubevirt-operator"].(map[string]interface{})["enabled"]).To(Equal(true)) + Expect(applications["kubevirt-cr"].(map[string]interface{})["enabled"]).To(Equal(true)) + Expect(applications["cdi-operator"].(map[string]interface{})["enabled"]).To(Equal(true)) + Expect(applications["cdi-cr"].(map[string]interface{})["enabled"]).To(Equal(true)) + Expect(bs.Env.InstallConfig.Datacenter.ID).To(Equal(1)) Expect(bs.Env.InstallConfig.Datacenter.Name).To(Equal("dev")) Expect(bs.Env.InstallConfig.Codesphere.Domain).To(Equal("cs.example.com")) @@ -372,7 +421,7 @@ var _ = Describe("Installconfig & Secrets", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -381,6 +430,33 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(bs.Env.InstallConfig.Datacenter.Name).To(Equal("staging")) }) + It("wires the openfga backup config and secrets when a bucket was provisioned", func() { + csEnv.OpenfgaBackupBucket = "pid-openfga-backup" + csEnv.OpenfgaBackupAccessKeyID = "access-id" + csEnv.OpenfgaBackupSecret = "secret-key" + + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + ob := bs.Env.InstallConfig.Codesphere.OpenfgaBackups + Expect(ob).NotTo(BeNil()) + Expect(ob.Enabled).To(BeTrue()) + Expect(ob.DestinationPath).To(Equal("s3://pid-openfga-backup")) + Expect(ob.EndpointURL).To(Equal("https://storage.googleapis.com")) + + accessKey := vault.GetSecret(files.SecretOpenfgaDbBackupAccessKeyId) + Expect(accessKey).NotTo(BeNil()) + Expect(accessKey.Fields.Password).To(Equal("access-id")) + secretKey := vault.GetSecret(files.SecretOpenfgaDbBackupSecretAccessKey) + Expect(secretKey).NotTo(BeNil()) + Expect(secretKey.Fields.Password).To(Equal("secret-key")) + }) Context("When internal flags are set in CodesphereEnvironment", func() { BeforeEach(func() { csEnv.InternalFlags = []string{"fake-exp1", "fake-exp2"} @@ -388,7 +464,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("uses those internal flags instead of defaults", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -405,7 +481,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("uses those preview flags instead of defaults", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -422,7 +498,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("uses those feature flags", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -439,7 +515,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("writes the email to the install config", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -456,7 +532,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("keeps the value of an existing config", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -473,7 +549,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting GitHub OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -492,7 +568,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets GitLab OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -516,7 +592,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting GitLab OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -534,7 +610,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Bitbucket OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -558,7 +634,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting Bitbucket OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -576,7 +652,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Azure DevOps OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -601,7 +677,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting Azure DevOps OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -621,7 +697,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets OIDC OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -648,7 +724,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("defaults OIDC provider name to OIDC", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -662,7 +738,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting OIDC OAuth configuration", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -680,7 +756,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets CentralOtel credentials in install config with Enabled true", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -696,7 +772,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("stores CentralOtel username and password in the vault", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -718,7 +794,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting CentralOtel credentials", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -736,7 +812,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting CentralOtel credentials", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -750,7 +826,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("leaves Monitoring.CentralOtel nil", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -768,7 +844,7 @@ var _ = Describe("Installconfig & Secrets", func() { gc.EXPECT().CreatePublicCAExternalAccountKey(mock.Anything).Return("fake-eab-key-id", "fake-eab-mac-key", nil) icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -800,7 +876,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("uses the Let's Encrypt staging directory", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -820,7 +896,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets OpenBao config in install config", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -842,7 +918,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Grafana Alloy Loki config", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -858,7 +934,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("stores External Loki credentials in the vault", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -877,7 +953,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Grafana Alloy Loki config without a password secret", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -900,7 +976,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Prometheus remote write config", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -920,7 +996,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("leaves Prometheus remote write nil", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -939,7 +1015,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets TelemetryExport with RemoteExport true", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -962,7 +1038,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets TelemetryExport with Traces true and RemoteExport false", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -987,7 +1063,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets TelemetryExport with both RemoteExport and Traces true", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -1006,7 +1082,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("leaves TelemetryExport nil", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -1035,10 +1111,10 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(err.Error()).To(ContainSubstring("failed to write config file")) }) - It("fails when WriteUnencryptedVault fails", func() { + It("fails when WriteVault fails", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(fmt.Errorf("vault write error")) + icg.EXPECT().WriteVault("fake-secret", true).Return(fmt.Errorf("vault write error")) err := bs.UpdateInstallConfig() Expect(err).To(HaveOccurred()) @@ -1048,7 +1124,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("fails when CopyFile config fails", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(fmt.Errorf("copy error")).Once() @@ -1060,7 +1136,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("fails when CopyFile secrets fails", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, "fake-config-file", mock.Anything).Return(nil).Once() nodeClient.EXPECT().CopyFile(mock.Anything, "fake-secret", mock.Anything).Return(fmt.Errorf("copy error")).Once() @@ -1088,6 +1164,7 @@ var _ = Describe("Installconfig & Secrets", func() { vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresCaKeyPem, File: &files.SecretFile{Name: "ca.key", Content: caKey}}) vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresPrimaryServerKeyPem, File: &files.SecretFile{Name: "primary.key", Content: key}}) + csEnv.InstallConfig.Postgres.CACertPem = caCert csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.1" csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" @@ -1099,7 +1176,7 @@ var _ = Describe("Installconfig & Secrets", func() { origCert := csEnv.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -1120,6 +1197,7 @@ var _ = Describe("Installconfig & Secrets", func() { vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresCaKeyPem, File: &files.SecretFile{Name: "ca.key", Content: caKey}}) vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresPrimaryServerKeyPem, File: &files.SecretFile{Name: "primary.key", Content: key}}) + csEnv.InstallConfig.Postgres.CACertPem = caCert csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.99" csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" @@ -1130,7 +1208,7 @@ var _ = Describe("Installconfig & Secrets", func() { origKey := vault.GetSecret(files.SecretPostgresPrimaryServerKeyPem).File.Content icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -1157,6 +1235,7 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(err).NotTo(HaveOccurred()) vault.SetSecret(files.SecretEntry{Name: files.SecretPostgresCaKeyPem, File: &files.SecretFile{Name: "ca.key", Content: caKey}}) + csEnv.InstallConfig.Postgres.CACertPem = caCert csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.1" csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" @@ -1164,7 +1243,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("generates new cert/key pair", func() { icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() diff --git a/internal/bootstrap/gcp/install_config_test_helpers_test.go b/internal/bootstrap/gcp/install_config_test_helpers_test.go new file mode 100644 index 000000000..632675706 --- /dev/null +++ b/internal/bootstrap/gcp/install_config_test_helpers_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp_test + +import ( + "github.com/codesphere-cloud/oms/internal/installer" + . "github.com/onsi/gomega" +) + +func newPlainInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("plain", "") + Expect(err).NotTo(HaveOccurred()) + + return manager +} diff --git a/internal/bootstrap/gcp/k0s.go b/internal/bootstrap/gcp/k0s.go new file mode 100644 index 000000000..2f505ef6c --- /dev/null +++ b/internal/bootstrap/gcp/k0s.go @@ -0,0 +1,254 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "fmt" + "strings" + + "github.com/codesphere-cloud/oms/internal/bootstrap/datacenter" + "github.com/codesphere-cloud/oms/internal/installer" +) + +// EnsureK0s executed all steps to ensure a k0s cluster in gcp for every data center. +// Only executing the config script needs to be done after installing codesphere, as crucial parts are still in the ts-installer. +// Returns an error if k0s could not be ensured. +func (b *GCPBootstrapper) EnsureK0s() error { + err := b.GenerateK0sConfigScript() + if err != nil { + return fmt.Errorf("failed to generate k0s config script: %w", err) + } + + err = b.InstallK0s() + if err != nil { + return fmt.Errorf("failed to install k0s: %w", err) + } + + err = b.WaitForK0sNodes() + if err != nil { + return fmt.Errorf("failed waiting for k0s nodes to get ready: %w", err) + } + + return nil +} + +// GenerateK0sConfigScript writes and uploads the k0s cloud-provider configuration script of +// every data center to that data center's first control plane node. +// Returns an error if a script can't be generated, written, copied. +func (b *GCPBootstrapper) GenerateK0sConfigScript() error { + if err := b.ensureDataCenters(); err != nil { + return err + } + + for _, dc := range b.Env.DataCenters { + if err := b.generateK0sConfigScript(dc); err != nil { + return err + } + } + + return nil +} + +func (b *GCPBootstrapper) generateK0sConfigScript(dc *datacenter.DataCenter) error { + var enableWorkerDaemonsCmds strings.Builder + + for i := 1; i < len(dc.ControlPlaneNodes); i++ { + internalIP := dc.ControlPlaneNodes[i].GetInternalIP() + fmt.Fprintf(&enableWorkerDaemonsCmds, "ssh -o StrictHostKeyChecking=no root@%s sed -i 's/k0sworker/k0sworker --enable-cloud-provider/g' /etc/systemd/system/k0sworker.service; systemctl daemon-reload; systemctl restart k0sworker", internalIP) + fmt.Fprint(&enableWorkerDaemonsCmds, "\n") + } + + script := fmt.Sprintf(`#!/bin/bash + +cat < cloud.conf +[Global] +project-id = "$PROJECT_ID" +EOF + +cat <> cc-deployment.yaml +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: cloud-controller-manager + namespace: kube-system + labels: + component: cloud-controller-manager +spec: + selector: + matchLabels: + component: cloud-controller-manager + template: + metadata: + labels: + component: cloud-controller-manager + spec: + serviceAccountName: cloud-controller-manager + containers: + - name: cloud-controller-manager + image: k8scloudprovidergcp/cloud-controller-manager:latest + command: + - /usr/local/bin/cloud-controller-manager + args: + - --v=5 + - --cloud-provider=gce + - --cloud-config=/etc/gce/cloud.conf + - --leader-elect-resource-name=k0s-gcp-ccm + - --use-service-account-credentials=true + - --controllers=cloud-node,cloud-node-lifecycle,service + - --allocate-node-cidrs=false + - --configure-cloud-routes=false + volumeMounts: + - name: cloud-config-volume + mountPath: /etc/gce + readOnly: true + volumes: + - name: cloud-config-volume + configMap: + name: cloud-config + tolerations: + - key: node.cloudprovider.kubernetes.io/uninitialized + value: "true" + effect: NoSchedule + - key: node-role.kubernetes.io/master + effect: NoSchedule + - key: node-role.kubernetes.io/control-plane + effect: NoSchedule +EOF + +KUBECTL="/etc/codesphere/deps/kubernetes/files/k0s kubectl" +$KUBECTL create configmap cloud-config --from-file=cloud.conf -n kube-system +echo alias kubectl=\"$KUBECTL\" >> /root/.bashrc +echo alias k=\"$KUBECTL\" >> /root/.bashrc + +$KUBECTL apply -f https://raw.githubusercontent.com/kubernetes/cloud-provider-gcp/refs/tags/providers/v0.28.2/deploy/packages/default/manifest.yaml + +$KUBECTL apply -f cc-deployment.yaml + +# set loadBalancerIP for public-gateway-controller and gateway-controller +$KUBECTL patch svc public-gateway-controller -n codesphere -p '{"spec": {"loadBalancerIP": "'%s'"}}' +$KUBECTL patch svc gateway-controller -n codesphere -p '{"spec": {"loadBalancerIP": "'%s'"}}' + +%s + +sed -i 's/k0scontroller/k0scontroller --enable-cloud-provider/g' /etc/systemd/system/k0scontroller.service +systemctl daemon-reload +systemctl restart k0scontroller +`, dc.PublicGatewayIP, dc.GatewayIP, enableWorkerDaemonsCmds.String()) + + // Probably we need to enable the cloud provider plugin in k0s configuration. + // --enable-cloud-provider on worker nodes systemd file /etc/systemd/system/k0sworker.service + // in addition on the first node: /etc/systemd/system/k0scontroller.service the flag --enable-cloud-provider + + localScript := dc.K0sConfigScriptPath() + + err := b.fw.WriteFile(localScript, []byte(script), 0755) + if err != nil { + return fmt.Errorf("failed to write %s: %w", localScript, err) + } + + controller := dc.ControlPlaneNodes[0] + + err = controller.NodeClient.CopyFile(controller, localScript, remoteK0sConfigScriptPath) + if err != nil { + return fmt.Errorf("failed to copy %s to control plane node: %w", localScript, err) + } + + err = controller.RunSSHCommand("root", "chmod +x "+remoteK0sConfigScriptPath) + if err != nil { + return fmt.Errorf("failed to make %s executable: %w", localScript, err) + } + + return nil +} + +// RunK0sConfigScript runs every data center's k0s configuration script on its first control +// plane node. It requires that data center's Codesphere install to have completed, since the +// script patches the gateway services the install creates. +func (b *GCPBootstrapper) RunK0sConfigScript() error { + if err := b.ensureDataCenters(); err != nil { + return err + } + + for _, dc := range b.Env.DataCenters { + err := b.stlog.Step(dc.StepName("Run k0s config script"), func() error { + return b.runK0sConfigScript(dc) + }) + if err != nil { + return fmt.Errorf("failed to run k0s config script (data center %d): %w", dc.ID, err) + } + } + + return nil +} + +func (b *GCPBootstrapper) runK0sConfigScript(dc *datacenter.DataCenter) error { + err := dc.ControlPlaneNodes[0].RunSSHCommand("root", remoteK0sConfigScriptPath) + if err != nil { + return fmt.Errorf("failed to configure k0s in data center %d: %w", dc.ID, err) + } + + return nil +} + +// InstallK0s deploys k0s into every data center with the native OMS installer. Each data center +// gets its own cluster, so every run stores its kubeconfig in that data center's encrypted +// install vault for the remaining installer steps. +func (b *GCPBootstrapper) InstallK0s() error { + if err := b.ensureDataCenters(); err != nil { + return err + } + + for _, dc := range b.Env.DataCenters { + err := b.stlog.Step(dc.StepName("Install k0s"), func() error { + return b.installK0s(dc) + }) + if err != nil { + return fmt.Errorf("failed to install k0s (data center %d): %w", dc.ID, err) + } + } + + return nil +} + +func (b *GCPBootstrapper) installK0s(dc *datacenter.DataCenter) error { + // Reuse matching cached binaries and let k0sctl reconcile normally. Without + // --force, an unchanged cluster remains untouched on bootstrap retries. + installCmd := fmt.Sprintf("oms install k0s --version %s --install-config %s --vault %s --vault-priv-key %s", + installer.DefaultK0sVersion, dc.RemoteConfigPath, dc.RemoteVaultPath(), dc.RemoteAgeKeyPath()) + if err := b.Env.Jumpbox.RunSSHCommand("root", installCmd); err != nil { + return fmt.Errorf("failed to install k0s from jumpbox (data center %d): %w", dc.ID, err) + } + + return nil +} + +// WaitForK0sNodes restores the readiness barrier from the TypeScript +// Kubernetes setup. k0sctl apply completing is not sufficient for the +// Codesphere charts: all schedulable nodes must be Ready before gateway +// controllers and their admission webhooks are installed. +func (b *GCPBootstrapper) WaitForK0sNodes() error { + if err := b.ensureDataCenters(); err != nil { + return err + } + + for _, dc := range b.Env.DataCenters { + err := b.stlog.Step(dc.StepName("Wait for k0s nodes"), func() error { + return b.waitForK0sNodes(dc) + }) + if err != nil { + return fmt.Errorf("failed waiting for k0s nodes (data center %d): %w", dc.ID, err) + } + } + + return nil +} + +func (b *GCPBootstrapper) waitForK0sNodes(dc *datacenter.DataCenter) error { + const command = "k0s kubectl wait --for=condition=Ready nodes --all --timeout=30m" + if err := dc.ControlPlaneNodes[0].RunSSHCommand("root", command); err != nil { + return fmt.Errorf("k0s nodes did not become ready (data center %d): %w", dc.ID, err) + } + + return nil +} diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go index 5b3713253..7fff6c97f 100644 --- a/internal/bootstrap/gcp/mocks.go +++ b/internal/bootstrap/gcp/mocks.go @@ -312,6 +312,78 @@ func (_c *MockGCPClientManager_CreateFirewallRule_Call) RunAndReturn(run func(pr return _c } +// CreateHMACKey provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateHMACKey(projectID string, serviceAccountEmail string) (string, string, error) { + ret := _mock.Called(projectID, serviceAccountEmail) + + if len(ret) == 0 { + panic("no return value specified for CreateHMACKey") + } + + var r0 string + var r1 string + var r2 error + if returnFunc, ok := ret.Get(0).(func(string, string) (string, string, error)); ok { + return returnFunc(projectID, serviceAccountEmail) + } + if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { + r0 = returnFunc(projectID, serviceAccountEmail) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string) string); ok { + r1 = returnFunc(projectID, serviceAccountEmail) + } else { + r1 = ret.Get(1).(string) + } + if returnFunc, ok := ret.Get(2).(func(string, string) error); ok { + r2 = returnFunc(projectID, serviceAccountEmail) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockGCPClientManager_CreateHMACKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateHMACKey' +type MockGCPClientManager_CreateHMACKey_Call struct { + *mock.Call +} + +// CreateHMACKey is a helper method to define mock.On call +// - projectID string +// - serviceAccountEmail string +func (_e *MockGCPClientManager_Expecter) CreateHMACKey(projectID any, serviceAccountEmail any) *MockGCPClientManager_CreateHMACKey_Call { + return &MockGCPClientManager_CreateHMACKey_Call{Call: _e.mock.On("CreateHMACKey", projectID, serviceAccountEmail)} +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) Run(run func(projectID string, serviceAccountEmail string)) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) Return(accessID string, secret string, err error) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Return(accessID, secret, err) + return _c +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) RunAndReturn(run func(projectID string, serviceAccountEmail string) (string, string, error)) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Return(run) + return _c +} + // CreateInstance provides a mock function for the type MockGCPClientManager func (_mock *MockGCPClientManager) CreateInstance(projectID string, zone string, instance *computepb.Instance) error { ret := _mock.Called(projectID, zone, instance) @@ -1155,6 +1227,69 @@ func (_c *MockGCPClientManager_EnsureDNSRecordSets_Call) RunAndReturn(run func(p return _c } +// EnsureStorageBucket provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) EnsureStorageBucket(projectID string, bucketName string, location string) error { + ret := _mock.Called(projectID, bucketName, location) + + if len(ret) == 0 { + panic("no return value specified for EnsureStorageBucket") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = returnFunc(projectID, bucketName, location) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_EnsureStorageBucket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureStorageBucket' +type MockGCPClientManager_EnsureStorageBucket_Call struct { + *mock.Call +} + +// EnsureStorageBucket is a helper method to define mock.On call +// - projectID string +// - bucketName string +// - location string +func (_e *MockGCPClientManager_Expecter) EnsureStorageBucket(projectID any, bucketName any, location any) *MockGCPClientManager_EnsureStorageBucket_Call { + return &MockGCPClientManager_EnsureStorageBucket_Call{Call: _e.mock.On("EnsureStorageBucket", projectID, bucketName, location)} +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) Run(run func(projectID string, bucketName string, location string)) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) Return(err error) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) RunAndReturn(run func(projectID string, bucketName string, location string) error) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Return(run) + return _c +} + // GetAddress provides a mock function for the type MockGCPClientManager func (_mock *MockGCPClientManager) GetAddress(projectID string, region string, addressName string) (*computepb.Address, error) { ret := _mock.Called(projectID, region, addressName) diff --git a/internal/bootstrap/gcp/test_helpers_test.go b/internal/bootstrap/gcp/test_helpers_test.go index e7131a4ba..77acc0fee 100644 --- a/internal/bootstrap/gcp/test_helpers_test.go +++ b/internal/bootstrap/gcp/test_helpers_test.go @@ -24,6 +24,16 @@ import ( func protoString(s string) *string { return &s } +// vmNames returns the names of the given VM definitions, in order. +func vmNames(defs []gcp.VMDef) []string { + names := make([]string, len(defs)) + for i, def := range defs { + names[i] = def.Name + } + + return names +} + // makeInstance creates a computepb.Instance with the given status and IPs. func makeInstance(status, internalIP, externalIP string) *computepb.Instance { inst := &computepb.Instance{ @@ -39,6 +49,7 @@ func makeInstance(status, internalIP, externalIP string) *computepb.Instance { {NatIP: protoString(externalIP)}, } } + return inst } @@ -57,14 +68,18 @@ func makeStoppedInstance(internalIP, externalIP string) *computepb.Instance { // Uses .Times(numVMs * 2) to expect exactly 2 calls per VM (initial check + poll after create). func mockGetInstanceNotFoundThenRunning(gc *gcp.MockGCPClientManager, projectID, zone string, runningResp *computepb.Instance, numVMs int) { instanceCalls := make(map[string]int) + var mu sync.Mutex + gc.EXPECT().GetInstance(projectID, zone, mock.Anything).RunAndReturn(func(projectID, zone, name string) (*computepb.Instance, error) { mu.Lock() defer mu.Unlock() + instanceCalls[name]++ if instanceCalls[name] == 1 { return nil, status.Errorf(codes.NotFound, "not found") } + return runningResp, nil }).Times(numVMs * 2) } @@ -100,5 +115,6 @@ func newTestBootstrapperAll(csEnv *gcp.CodesphereEnvironment, gc gcp.GCPClientMa if err != nil { panic("newTestBootstrapperAll: " + err.Error()) } + return bs } diff --git a/internal/bootstrap/local/argocd_app.go b/internal/bootstrap/local/argocd_app.go new file mode 100644 index 000000000..bff5db138 --- /dev/null +++ b/internal/bootstrap/local/argocd_app.go @@ -0,0 +1,59 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package local implements single-node Codesphere cluster bootstrapping. +package local + +import ( + "encoding/json" + "fmt" + + argov1alpha1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" + "github.com/codesphere-cloud/oms/internal/installer/argocd" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +type helmApplicationConfig struct { + Name, Chart, RepoURL, TargetRevision, Namespace string + Values map[string]interface{} +} + +func (b *LocalBootstrapper) installHelmApplication(cfg helmApplicationConfig) error { + rawValues, err := json.Marshal(cfg.Values) + if err != nil { + return fmt.Errorf("failed to marshal values for ArgoCD Application %q: %w", cfg.Name, err) + } + + desired := &argov1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: cfg.Name, Namespace: argocd.DefaultNamespace}, + Spec: argov1alpha1.ApplicationSpec{ + Project: "default", + Source: &argov1alpha1.ApplicationSource{ + RepoURL: cfg.RepoURL, Chart: cfg.Chart, TargetRevision: cfg.TargetRevision, + Helm: &argov1alpha1.ApplicationSourceHelm{ReleaseName: cfg.Name, ValuesObject: &runtime.RawExtension{Raw: rawValues}}, + }, + Destination: argov1alpha1.ApplicationDestination{Server: "https://kubernetes.default.svc", Namespace: cfg.Namespace}, + SyncPolicy: &argov1alpha1.SyncPolicy{ + Automated: &argov1alpha1.SyncPolicyAutomated{Prune: ptr.To(true), SelfHeal: ptr.To(true)}, + SyncOptions: argov1alpha1.SyncOptions{"CreateNamespace=true", "ServerSideApply=true"}, + }, + }, + } + + current := &argov1alpha1.Application{ObjectMeta: desired.ObjectMeta} + if _, err := controllerutil.CreateOrUpdate(b.ctx, b.kubeClient, current, func() error { + current.Spec = desired.Spec + return nil + }); err != nil { + return fmt.Errorf("failed to apply ArgoCD Application %q: %w", cfg.Name, err) + } + + if err := argocd.WaitForApplicationHealthy(b.ctx, b.kubeClient, cfg.Name, cfg.TargetRevision, b.stlog.Logf); err != nil { + return fmt.Errorf("failed to wait for ArgoCD Application %q: %w", cfg.Name, err) + } + + return nil +} diff --git a/internal/bootstrap/local/installer.go b/internal/bootstrap/local/installer.go index 40e42dd74..425039e86 100644 --- a/internal/bootstrap/local/installer.go +++ b/internal/bootstrap/local/installer.go @@ -14,9 +14,10 @@ import ( "strings" "time" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/bom" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/portal" - "github.com/codesphere-cloud/oms/internal/util" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -44,11 +45,8 @@ func (b *LocalBootstrapper) DownloadInstallerPackage() (string, error) { if version == "" { return "", fmt.Errorf("install version is required to download from the portal") } - if hash == "" { - return "", fmt.Errorf("install hash must be set when install version is set") - } - log.Printf("Downloading Codesphere package %s (hash %s) from the OMS portal...", version, hash) + log.Printf("Downloading Codesphere package %s from the OMS portal...", version) p := portal.NewPortalClient() @@ -64,41 +62,8 @@ func (b *LocalBootstrapper) DownloadInstallerPackage() (string, error) { return destPath, nil } - download, err := build.GetBuildForDownload(installerArtifactFilename) - if err != nil { - return "", fmt.Errorf("artifact %q not found in build: %w", installerArtifactFilename, err) - } - - // Support resuming a partial download. - out, err := b.fw.OpenAppend(destPath) - if err != nil { - out, err = b.fw.Create(destPath) - if err != nil { - return "", fmt.Errorf("failed to create file %s: %w", destPath, err) - } - } - defer util.CloseFileIgnoreError(out) - - fileSize := 0 - fileInfo, err := out.Stat() - if err == nil { - fileSize = int(fileInfo.Size()) - } - - err = p.DownloadBuildArtifact(portal.CodesphereProduct, download, out, fileSize, false) - if err != nil { - return "", fmt.Errorf("failed to download build artifact: %w", err) - } - - // Verify integrity. - verifyFile, err := b.fw.Open(destPath) - if err != nil { - return "", fmt.Errorf("failed to open downloaded file for verification: %w", err) - } - defer util.CloseFileIgnoreError(verifyFile) - - if err := p.VerifyBuildArtifactDownload(verifyFile, download); err != nil { - return "", fmt.Errorf("artifact verification failed: %w", err) + if err := portal.DownloadAndVerifyBuild(p, b.fw, portal.CodesphereProduct, build, installerArtifactFilename, destPath, portal.DownloadOptions{Resume: true}); err != nil { + return "", fmt.Errorf("failed to download installer package: %w", err) } return destPath, nil @@ -106,7 +71,7 @@ func (b *LocalBootstrapper) DownloadInstallerPackage() (string, error) { // PrepareInstallerBundle resolves the installer package to a directory. // It handles three cases: -// 1. Portal download: InstallVersion+InstallHash are set → download tar.gz, then extract. +// 1. Portal download: InstallVersion is set → download tar.gz, then extract. // 2. Local tar.gz/tgz: InstallLocal points to an archive → extract. // 3. Local directory: InstallLocal points to an already-unpacked directory → use as-is. func (b *LocalBootstrapper) PrepareInstallerBundle() (string, error) { @@ -144,23 +109,35 @@ func (b *LocalBootstrapper) PrepareInstallerBundle() (string, error) { return "", fmt.Errorf("installer bundle %q is neither a directory nor a .tar.gz/.tgz archive", bundlePath) } - destDir := strings.TrimSuffix(strings.TrimSuffix(bundlePath, ".gz"), ".tar") - destDir = strings.TrimSuffix(destDir, ".tgz") - if destDir == bundlePath { - destDir = bundlePath + "-unpacked" + packageManager := installer.NewPackage(filepath.Dir(bundlePath), bundlePath) + if err := packageManager.Extract(false); err != nil { + return "", fmt.Errorf("failed to extract installer bundle: %w", err) } - if b.fw.Exists(destDir) { - log.Printf("Installer bundle is already extracted. Skipping extraction...\n") - return destDir, nil + return packageManager.GetWorkDir(), nil +} + +// PrepareInstaller resolves and extracts the installer bundle before the +// bootstrap needs any version information from its BOM. +func (b *LocalBootstrapper) PrepareInstaller() error { + if b.Env.InstallVersion == "" && b.Env.InstallLocal == "" { + return nil } - log.Printf("Extracting installer bundle %s → %s", bundlePath, destDir) - if err := util.ExtractTarGz(b.fw, bundlePath, destDir); err != nil { - return "", fmt.Errorf("failed to extract installer bundle: %w", err) + bundleDir, err := b.PrepareInstallerBundle() + if err != nil { + return err } - return destDir, nil + bomConfig, err := bom.Parse(filepath.Join(bundleDir, "deps", "bom.json")) + if err != nil { + return fmt.Errorf("failed to parse installer BOM: %w", err) + } + + b.installerBundleDir = bundleDir + b.installerBOM = bomConfig + + return nil } // symlinkLocalBinaries replaces bundled node, helm and kubectl binaries with @@ -393,8 +370,7 @@ func (b *LocalBootstrapper) configurePostgresForMigration(host string, port int3 }, nil } -// RunInstaller extracts the deps.tar.gz archive locally and then runs the -// install-components.js script directly on the local machine for each +// RunInstaller runs the install-components.js script directly on the local machine for each // required component step (setUpCluster, codesphere), instead of running // the private-cloud-installer.js which orchestrates remote nodes via SSH. func (b *LocalBootstrapper) RunInstaller() (err error) { @@ -403,9 +379,9 @@ func (b *LocalBootstrapper) RunInstaller() (err error) { return nil } - bundleDir, err := b.PrepareInstallerBundle() - if err != nil { - return fmt.Errorf("failed to prepare installer bundle: %w", err) + bundleDir := b.installerBundleDir + if bundleDir == "" { + return fmt.Errorf("installer bundle is not prepared") } // On non-Linux hosts the bundled binaries are Linux ELF executables that @@ -414,34 +390,22 @@ func (b *LocalBootstrapper) RunInstaller() (err error) { return fmt.Errorf("failed to symlink local binaries: %w", err) } - // Extract deps.tar.gz locally so that install-components.js can find - // all dependency binaries (helm charts, sops, etc.) on the local machine. - archivePath := filepath.Join(bundleDir, "deps.tar.gz") depsDir := filepath.Join(bundleDir, "deps") - if b.fw.Exists(depsDir) { - log.Printf("deps directory already exists at %s, skipping extraction", depsDir) - } else { - log.Printf("Extracting deps.tar.gz → %s", depsDir) - if err := util.ExtractTarGz(b.fw, archivePath, depsDir); err != nil { - return fmt.Errorf("failed to extract deps.tar.gz: %w", err) - } + if b.argoCDAndAppsInstall == nil { + return fmt.Errorf("ArgoCD and apps installer is not initialized") } - if b.Env.UseArgoCD { - if b.argoCDAndAppsInstall == nil { - return fmt.Errorf("ArgoCD and apps installer is not initialized") - } - if err := b.stlog.Substep("Sync vault secret", func() error { - return b.argoCDAndAppsInstall.SyncVaultSecret(b.ctx) - }); err != nil { - return err - } - if err := b.stlog.Substep("Register pc-apps app-of-apps", func() error { - return b.argoCDAndAppsInstall.InstallPCApps(b.ctx, filepath.Join(depsDir, "bom.json")) - }); err != nil { - return err - } + if err := b.stlog.Substep("Sync vault secret", func() error { + return b.argoCDAndAppsInstall.SyncVaultSecret(b.ctx) + }); err != nil { + return fmt.Errorf("failed to sync vault secret: %w", err) + } + + if err := b.stlog.Substep("Register pc-apps app-of-apps", func() error { + return b.argoCDAndAppsInstall.InstallPCApps(b.ctx, b.installerBOM) + }); err != nil { + return fmt.Errorf("failed to register pc-apps app-of-apps: %w", err) } // Symlink sops and age inside the extracted deps directory so that diff --git a/internal/bootstrap/local/local.go b/internal/bootstrap/local/local.go index 4dad4255a..be82d1d6d 100644 --- a/internal/bootstrap/local/local.go +++ b/internal/bootstrap/local/local.go @@ -15,8 +15,10 @@ import ( "github.com/codesphere-cloud/oms/internal/bootstrap" "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/argocd" + "github.com/codesphere-cloud/oms/internal/installer/bom" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" "github.com/codesphere-cloud/oms/internal/util" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -58,7 +60,6 @@ type LocalBootstrapper struct { restConfig *rest.Config fw util.FileIO icg installer.InstallConfigManager - helm installer.HelmClient // Environment Env *CodesphereEnvironment // cephCredentials holds the Ceph auth credentials read after setup. @@ -69,6 +70,8 @@ type LocalBootstrapper struct { ageKeyPath string // argoCDAndAppsInstall is reused for the ArgoCD, vault, and pc-apps stages. argoCDAndAppsInstall *argocd.AppInstaller + installerBundleDir string + installerBOM *bom.Config } type CodesphereEnvironment struct { @@ -85,21 +88,23 @@ type CodesphereEnvironment struct { RegistryUser string `json:"-"` RegistryPassword string `json:"-"` // Config - InstallDir string `json:"-"` - ExistingConfigUsed bool `json:"-"` - InstallConfigPath string `json:"-"` - SecretsFilePath string `json:"-"` - InstallConfig *files.RootConfig `json:"-"` - Vault *files.InstallVault `json:"-"` - K0s bool `json:"-"` - PodCIDR string `json:"pod_cidr"` - ServiceCIDR string `json:"service_cidr"` + InstallDir string `json:"-"` + ExistingConfigUsed bool `json:"-"` + InstallConfigPath string `json:"-"` + SecretsFilePath string `json:"-"` + InstallConfig *files.RootConfig `json:"-"` + Vault *files.InstallVault `json:"-"` + K0s bool `json:"-"` + PodCIDR string `json:"pod_cidr"` + ServiceCIDR string `json:"service_cidr"` + CephDeviceFilter string `json:"-"` + CephDevicePathFilter string `json:"-"` // ArgoCD integration - UseArgoCD bool `json:"-"` ArgoCDRegistryURL string `json:"-"` } -func NewLocalBootstrapper(ctx context.Context, stlog *bootstrap.StepLogger, kubeClient client.Client, restConfig *rest.Config, fw util.FileIO, icg installer.InstallConfigManager, helm installer.HelmClient, env *CodesphereEnvironment) *LocalBootstrapper { +// NewLocalBootstrapper creates a bootstrapper for a local Codesphere cluster. +func NewLocalBootstrapper(ctx context.Context, stlog *bootstrap.StepLogger, kubeClient client.Client, restConfig *rest.Config, fw util.FileIO, icg installer.InstallConfigManager, env *CodesphereEnvironment) *LocalBootstrapper { return &LocalBootstrapper{ ctx: ctx, stlog: stlog, @@ -107,13 +112,17 @@ func NewLocalBootstrapper(ctx context.Context, stlog *bootstrap.StepLogger, kube restConfig: restConfig, fw: fw, icg: icg, - helm: helm, Env: env, } } func (b *LocalBootstrapper) Bootstrap() error { - err := b.stlog.Step("Ensure install config", b.EnsureInstallConfig) + err := b.stlog.Step("Prepare installer bundle", b.PrepareInstaller) + if err != nil { + return fmt.Errorf("failed to prepare installer bundle: %w", err) + } + + err = b.stlog.Step("Ensure install config", b.EnsureInstallConfig) if err != nil { return fmt.Errorf("failed to ensure install config: %w", err) } @@ -133,11 +142,9 @@ func (b *LocalBootstrapper) Bootstrap() error { return fmt.Errorf("failed to ensure namespaces: %w", err) } - if b.Env.UseArgoCD { - err = b.stlog.Step("Bootstrap ArgoCD", b.BootstrapArgoCD) - if err != nil { - return fmt.Errorf("failed to bootstrap ArgoCD: %w", err) - } + err = b.stlog.Step("Bootstrap ArgoCD", b.BootstrapArgoCD) + if err != nil { + return fmt.Errorf("failed to bootstrap ArgoCD: %w", err) } err = b.stlog.Step("Install Rook and test Ceph cluster", func() error { @@ -509,34 +516,22 @@ func (b *LocalBootstrapper) EnsureInstallConfig() error { } func (b *LocalBootstrapper) loadVaultForConfigTemplating() error { - if !b.fw.Exists(b.Env.SecretsFilePath) { - return nil - } - - // The local prod.vault.yaml file itn eh secrets dir is always unecrpyted. - // The encrypted version is in the "secrets" dir. if err := b.icg.LoadVaultFromUnecryptedFile(b.Env.SecretsFilePath); err != nil { return fmt.Errorf("failed to load vault file for config templating: %w", err) } - return nil } func (b *LocalBootstrapper) EnsureSecrets() error { - if b.fw.Exists(b.Env.SecretsFilePath) { - err := b.icg.LoadVaultFromUnecryptedFile(b.Env.SecretsFilePath) - if err != nil { - return fmt.Errorf("failed to load vault file: %w", err) - } + if err := b.icg.LoadVaultFromUnecryptedFile(b.Env.SecretsFilePath); err != nil { + return fmt.Errorf("failed to load vault file: %w", err) } - b.Env.Vault = b.icg.GetVault() - return nil } func (b *LocalBootstrapper) ResolveAgeKey() error { - recipient, keyPath, err := vault.ResolveAgeKey("", filepath.Dir(b.Env.SecretsFilePath)) + recipient, keyPath, err := sops.ResolveAgeKey("", filepath.Dir(b.Env.SecretsFilePath)) if err != nil { return fmt.Errorf("failed to resolve age key: %w", err) } @@ -669,11 +664,21 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { return fmt.Errorf("failed to write config file: %w", err) } - if err := b.icg.WriteUnencryptedVault(b.Env.SecretsFilePath, true); err != nil { + if err := b.icg.WriteVault(b.Env.SecretsFilePath, true); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } - if err := vault.EncryptFileWithSOPS(b.Env.SecretsFilePath, filepath.Join(b.Env.InstallConfig.Secrets.BaseDir, "prod.vault.yaml"), b.ageRecipient); err != nil { - return fmt.Errorf("failed to encrypt vault file: %w", err) + + installerVault, err := vault.New(vault.TypeSOPS, vault.Options{ + Path: filepath.Join(b.Env.InstallConfig.Secrets.BaseDir, "prod.vault.yaml"), + AgeKey: b.ageKeyPath, + WithComments: true, + }) + if err != nil { + return fmt.Errorf("failed to load installer vault: %w", err) + } + + if err := installerVault.Save(b.Env.Vault); err != nil { + return fmt.Errorf("failed to save installer vault: %w", err) } return nil diff --git a/internal/bootstrap/local/postgres.go b/internal/bootstrap/local/postgres.go index d7747af7f..f51895dcf 100644 --- a/internal/bootstrap/local/postgres.go +++ b/internal/bootstrap/local/postgres.go @@ -10,7 +10,6 @@ import ( "time" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" - "github.com/codesphere-cloud/oms/internal/installer" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -32,15 +31,22 @@ const ( cnpgReadyTimeout = 15 * time.Minute cnpgReadyPollInterval = 5 * time.Second cnpgSecretPasswordKey = "password" + cnpgBOMComponent = "postgres-operator" ) func (b *LocalBootstrapper) InstallCloudNativePGHelmChart() error { - if err := b.helm.UpgradeChart(b.ctx, installer.ChartConfig{ - ReleaseName: cnpgReleaseName, - ChartName: "cloudnative-pg", - RepoURL: cnpgRepoURL, - Namespace: codesphereNamespace, - CreateNamespace: true, + if b.installerBOM == nil { + return fmt.Errorf("installer BOM is not prepared") + } + + chart, ok := b.installerBOM.GetChart(cnpgBOMComponent) + if !ok { + return fmt.Errorf("CloudNativePG chart is missing from BOM component %q", cnpgBOMComponent) + } + + return b.installHelmApplication(helmApplicationConfig{ + Name: cnpgReleaseName, Chart: "cloudnative-pg", RepoURL: cnpgRepoURL, + TargetRevision: chart.Tag(), Namespace: codesphereNamespace, Values: map[string]interface{}{ "config": map[string]interface{}{ "clusterWide": true, @@ -55,11 +61,7 @@ func (b *LocalBootstrapper) InstallCloudNativePGHelmChart() error { }, }, }, - }, installer.UpgradeChartOptions{InstallIfNotExist: true}); err != nil { - return fmt.Errorf("failed to deploy Helm chart %q: %w", cnpgReleaseName, err) - } - - return nil + }) } func (b *LocalBootstrapper) DeployPostgresDatabase() error { diff --git a/internal/bootstrap/local/rook.go b/internal/bootstrap/local/rook.go index fc2330b75..e9417ae3d 100644 --- a/internal/bootstrap/local/rook.go +++ b/internal/bootstrap/local/rook.go @@ -8,9 +8,9 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" - "github.com/codesphere-cloud/oms/internal/installer" rookcephv1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" @@ -34,9 +34,11 @@ const ( rookReadyTimeout = 30 * time.Minute rookReadyPollInterval = 5 * time.Second - cephBlockPoolName = "codesphere-rbd" - cephStorageClassName = "codesphere-rbd" - cephRBDProvisionerName = "rook-ceph.rbd.csi.ceph.com" + cephBlockPoolName = "codesphere-rbd" + cephStorageClassName = "codesphere-rbd" + cephRBDProvisionerName = "rook-ceph.rbd.csi.ceph.com" + defaultStorageClassAnnotation = "storageclass.kubernetes.io/is-default-class" + legacyDefaultClassAnnotation = "storageclass.beta.kubernetes.io/is-default-class" ) // csiResourceEntry represents a single container resource definition for Rook CSI drivers. @@ -139,19 +141,10 @@ func (b *LocalBootstrapper) InstallRookHelmChart() error { return fmt.Errorf("failed to build Helm values: %w", err) } - if err := b.helm.UpgradeChart(b.ctx, installer.ChartConfig{ - ReleaseName: rookReleaseName, - ChartName: "rook-ceph", - RepoURL: rookRepoURL, - Version: rookVersion, - Namespace: rookNamespace, - CreateNamespace: true, - Values: helmValues, - }, installer.UpgradeChartOptions{InstallIfNotExist: true}); err != nil { - return fmt.Errorf("failed to deploy Helm chart %q: %w", rookReleaseName, err) - } - - return nil + return b.installHelmApplication(helmApplicationConfig{ + Name: rookReleaseName, Chart: "rook-ceph", RepoURL: rookRepoURL, + TargetRevision: rookVersion, Namespace: rookNamespace, Values: helmValues, + }) } func (b *LocalBootstrapper) DeployTestCephCluster() error { @@ -178,11 +171,8 @@ func (b *LocalBootstrapper) DeployTestCephCluster() error { AllowMultiplePerNode: true, }, Storage: rookcephv1.StorageScopeSpec{ - // TODO: make configurable. - UseAllNodes: true, - Selection: rookcephv1.Selection{ - UseAllDevices: ptr.To(true), - }, + UseAllNodes: true, + Selection: b.cephDeviceSelection(), AllowDeviceClassUpdate: true, AllowOsdCrushWeightUpdate: false, }, @@ -241,6 +231,17 @@ func (b *LocalBootstrapper) DeployTestCephCluster() error { return nil } +func (b *LocalBootstrapper) cephDeviceSelection() rookcephv1.Selection { + selection := rookcephv1.Selection{UseAllDevices: ptr.To(true)} + if b.Env.CephDeviceFilter != "" || b.Env.CephDevicePathFilter != "" { + selection.UseAllDevices = ptr.To(false) + selection.DeviceFilter = b.Env.CephDeviceFilter + selection.DevicePathFilter = b.Env.CephDevicePathFilter + } + + return selection +} + func (b *LocalBootstrapper) WaitForTestCephClusterReady() error { ctx, cancel := context.WithTimeout(b.ctx, rookReadyTimeout) defer cancel() @@ -339,6 +340,13 @@ func (b *LocalBootstrapper) DeployCephBlockPoolAndStorageClass() error { return fmt.Errorf("failed to create or update CephBlockPool %q: %w", cephBlockPoolName, err) } + storageClasses := &storagev1.StorageClassList{} + if err := b.kubeClient.List(b.ctx, storageClasses); err != nil { + return fmt.Errorf("failed to list StorageClasses: %w", err) + } + + hasOtherDefault := hasDefaultStorageClass(storageClasses.Items, cephStorageClassName) + // Create StorageClass reclaimPolicy := corev1.PersistentVolumeReclaimDelete volumeBindingMode := storagev1.VolumeBindingImmediate @@ -349,6 +357,16 @@ func (b *LocalBootstrapper) DeployCephBlockPoolAndStorageClass() error { } _, err = controllerutil.CreateOrUpdate(b.ctx, b.kubeClient, storageClass, func() error { + if storageClass.Annotations == nil { + storageClass.Annotations = make(map[string]string) + } + + if hasOtherDefault { + delete(storageClass.Annotations, defaultStorageClassAnnotation) + delete(storageClass.Annotations, legacyDefaultClassAnnotation) + } else { + storageClass.Annotations[defaultStorageClassAnnotation] = "true" + } storageClass.Provisioner = cephRBDProvisionerName storageClass.Parameters = map[string]string{ "clusterID": rookNamespace, @@ -375,6 +393,21 @@ func (b *LocalBootstrapper) DeployCephBlockPoolAndStorageClass() error { return nil } +func hasDefaultStorageClass(storageClasses []storagev1.StorageClass, excludeName string) bool { + for _, storageClass := range storageClasses { + if storageClass.Name == excludeName { + continue + } + + if strings.EqualFold(storageClass.Annotations[defaultStorageClassAnnotation], "true") || + strings.EqualFold(storageClass.Annotations[legacyDefaultClassAnnotation], "true") { + return true + } + } + + return false +} + func isRookCephClusterReady(cluster *rookcephv1.CephCluster) bool { if cluster == nil { return false diff --git a/internal/bootstrap/local/rook_selection_test.go b/internal/bootstrap/local/rook_selection_test.go new file mode 100644 index 000000000..ed4879333 --- /dev/null +++ b/internal/bootstrap/local/rook_selection_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package local + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Ceph device selection", func() { + It("uses all devices by default", func() { + selection := (&LocalBootstrapper{Env: &CodesphereEnvironment{}}).cephDeviceSelection() + Expect(selection.UseAllDevices).NotTo(BeNil()) + Expect(*selection.UseAllDevices).To(BeTrue()) + Expect(selection.DeviceFilter).To(BeEmpty()) + Expect(selection.DevicePathFilter).To(BeEmpty()) + }) + + It("uses only the configured device-name filter", func() { + selection := (&LocalBootstrapper{Env: &CodesphereEnvironment{CephDeviceFilter: "^sd[b-z]$"}}).cephDeviceSelection() + Expect(*selection.UseAllDevices).To(BeFalse()) + Expect(selection.DeviceFilter).To(Equal("^sd[b-z]$")) + Expect(selection.DevicePathFilter).To(BeEmpty()) + }) + + It("uses only the configured device-path filter", func() { + selection := (&LocalBootstrapper{Env: &CodesphereEnvironment{CephDevicePathFilter: "^/dev/disk/by-id/"}}).cephDeviceSelection() + Expect(*selection.UseAllDevices).To(BeFalse()) + Expect(selection.DeviceFilter).To(BeEmpty()) + Expect(selection.DevicePathFilter).To(Equal("^/dev/disk/by-id/")) + }) + + It("passes both explicit filters to Rook", func() { + selection := (&LocalBootstrapper{Env: &CodesphereEnvironment{ + CephDeviceFilter: "^nvme", + CephDevicePathFilter: "^/dev/disk/by-id/", + }}).cephDeviceSelection() + Expect(*selection.UseAllDevices).To(BeFalse()) + Expect(selection.DeviceFilter).To(Equal("^nvme")) + Expect(selection.DevicePathFilter).To(Equal("^/dev/disk/by-id/")) + }) +}) + +var _ = Describe("Default storage class selection", func() { + It("detects another stable default storage class", func() { + classes := []storagev1.StorageClass{{ + ObjectMeta: metav1.ObjectMeta{Name: "existing", Annotations: map[string]string{ + defaultStorageClassAnnotation: "true", + }}, + }} + Expect(hasDefaultStorageClass(classes, cephStorageClassName)).To(BeTrue()) + }) + + It("detects another legacy default storage class", func() { + classes := []storagev1.StorageClass{{ + ObjectMeta: metav1.ObjectMeta{Name: "existing", Annotations: map[string]string{ + legacyDefaultClassAnnotation: "TRUE", + }}, + }} + Expect(hasDefaultStorageClass(classes, cephStorageClassName)).To(BeTrue()) + }) + + It("ignores the Codesphere storage class itself", func() { + classes := []storagev1.StorageClass{{ + ObjectMeta: metav1.ObjectMeta{Name: cephStorageClassName, Annotations: map[string]string{ + defaultStorageClassAnnotation: "true", + }}, + }} + Expect(hasDefaultStorageClass(classes, cephStorageClassName)).To(BeFalse()) + }) +}) diff --git a/internal/codesphere/mocks.go b/internal/codesphere/mocks.go index c26af3464..6785aa7d8 100644 --- a/internal/codesphere/mocks.go +++ b/internal/codesphere/mocks.go @@ -280,8 +280,8 @@ func (_c *MockClient_GetPipelineState_Call) Run(run func(workspaceID int, stage return _c } -func (_c *MockClient_GetPipelineState_Call) Return(vs []api.PipelineStatus, err error) *MockClient_GetPipelineState_Call { - _c.Call.Return(vs, err) +func (_c *MockClient_GetPipelineState_Call) Return(pipelineStatuss []api.PipelineStatus, err error) *MockClient_GetPipelineState_Call { + _c.Call.Return(pipelineStatuss, err) return _c } @@ -397,8 +397,8 @@ func (_c *MockClient_ListWorkspacePlans_Call) Run(run func()) *MockClient_ListWo return _c } -func (_c *MockClient_ListWorkspacePlans_Call) Return(vs []api.WorkspacePlan, err error) *MockClient_ListWorkspacePlans_Call { - _c.Call.Return(vs, err) +func (_c *MockClient_ListWorkspacePlans_Call) Return(workspacePlans []api.WorkspacePlan, err error) *MockClient_ListWorkspacePlans_Call { + _c.Call.Return(workspacePlans, err) return _c } diff --git a/internal/codesphere/testplan/testplan.go b/internal/codesphere/testplan/testplan.go new file mode 100644 index 000000000..fdc1fe42c --- /dev/null +++ b/internal/codesphere/testplan/testplan.go @@ -0,0 +1,347 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package testplan runs ordered playlists of tests against a Codesphere +// installation and reports their results. +// +// A Test is a single, self-contained check (for example a status report or a +// smoke test). A Playlist is a named, ordered selection of those tests, so +// operators can run a well-known set of checks with a single command. +package testplan + +import ( + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + "text/tabwriter" + "time" +) + +const ( + // ANSI color codes + colorGreen = "\033[32m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorBold = "\033[1m" + colorReset = "\033[0m" +) + +// Status is the outcome of a single test run. +type Status string + +// The outcomes a test can have. A test that was not run at all, because an +// earlier test failed or the run was cancelled, is skipped. +const ( + StatusPassed Status = "PASS" + StatusFailed Status = "FAIL" + StatusSkipped Status = "SKIP" +) + +func (s Status) colored() string { + switch s { + case StatusPassed: + return colorGreen + string(s) + colorReset + case StatusFailed: + return colorRed + string(s) + colorReset + default: + return colorYellow + string(s) + colorReset + } +} + +// Test is a single, independently runnable check of a Codesphere installation. +type Test interface { + Name() string + Description() string + Run(ctx context.Context, out io.Writer) error +} + +// Func adapts a plain function into a Test. +type Func struct { + TestName string + Desc string + Fn func(ctx context.Context, out io.Writer) error +} + +// Name returns the name the test is selected by. +func (f *Func) Name() string { return f.TestName } + +// Description returns what the test does, as shown in listings and progress logs. +func (f *Func) Description() string { return f.Desc } + +// Run executes the wrapped function. +func (f *Func) Run(ctx context.Context, out io.Writer) error { + return f.Fn(ctx, out) +} + +// Result records the outcome of a single test. +type Result struct { + Name string + Status Status + Duration time.Duration + Err error +} + +// Playlist is a named, ordered selection of tests. +type Playlist struct { + Name string + Description string + Tests []string +} + +// Registry holds the tests that can be run and the playlists that select them. +type Registry struct { + tests []Test + playlists []Playlist +} + +// NewRegistry returns a registry of the given tests, in the order they are +// passed. Tests keep that order unless a playlist specifies a different one. +func NewRegistry(tests ...Test) *Registry { + return &Registry{tests: tests} +} + +// AddPlaylist registers a named selection of tests. +func (r *Registry) AddPlaylist(p Playlist) { + r.playlists = append(r.playlists, p) +} + +// Tests returns all registered tests. +func (r *Registry) Tests() []Test { + return slices.Clone(r.tests) +} + +// Playlists returns all registered playlists. +func (r *Registry) Playlists() []Playlist { + return slices.Clone(r.playlists) +} + +// TestNames returns the names of all registered tests, in registration order. +func (r *Registry) TestNames() []string { + names := make([]string, 0, len(r.tests)) + for _, t := range r.tests { + names = append(names, t.Name()) + } + + return names +} + +// PlaylistNames returns the names of all registered playlists. +func (r *Registry) PlaylistNames() []string { + names := make([]string, 0, len(r.playlists)) + for _, p := range r.playlists { + names = append(names, p.Name) + } + + return names +} + +// Select resolves test names to tests, keeping the requested order. Unknown +// names are reported instead of silently ignored, so a typo doesn't quietly +// shrink the test run. +func (r *Registry) Select(names []string) ([]Test, error) { + if len(names) == 0 { + return nil, errors.New("no tests selected") + } + + byName := make(map[string]Test, len(r.tests)) + for _, t := range r.tests { + byName[t.Name()] = t + } + + selected := make([]Test, 0, len(names)) + + var unknown []string + + for _, name := range names { + test, ok := byName[name] + if !ok { + unknown = append(unknown, name) + continue + } + + if slices.ContainsFunc(selected, func(t Test) bool { return t.Name() == name }) { + continue + } + + selected = append(selected, test) + } + + if len(unknown) > 0 { + return nil, fmt.Errorf("unknown test(s) %s, available tests are %s", + strings.Join(unknown, ","), strings.Join(r.TestNames(), ",")) + } + + return selected, nil +} + +// SelectPlaylist resolves a playlist name to the tests it contains. +func (r *Registry) SelectPlaylist(name string) ([]Test, error) { + idx := slices.IndexFunc(r.playlists, func(p Playlist) bool { return p.Name == name }) + if idx < 0 { + return nil, fmt.Errorf("unknown playlist %q, available playlists are %s", + name, strings.Join(r.PlaylistNames(), ",")) + } + + tests, err := r.Select(r.playlists[idx].Tests) + if err != nil { + return nil, fmt.Errorf("playlist %q: %w", name, err) + } + + return tests, nil +} + +// Describe writes the available tests and playlists in a human readable form. +func (r *Registry) Describe(w io.Writer) { + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + + printf(tw, "%sTests%s\n", colorBold, colorReset) + + for _, t := range r.tests { + printf(tw, " %s\t%s\n", t.Name(), t.Description()) + } + + printf(tw, "\n%sPlaylists%s\n", colorBold, colorReset) + + for _, p := range r.playlists { + printf(tw, " %s\t%s\t[%s]\n", p.Name, p.Description, strings.Join(p.Tests, ", ")) + } + + //nolint:errcheck // flushing to the command's output stream, nothing to recover from + tw.Flush() +} + +// Runner executes tests in order and reports what happened. +type Runner struct { + // Out receives both the progress log and the output of the tests themselves. + Out io.Writer + // FailFast skips the remaining tests as soon as one fails. + FailFast bool + // Quiet suppresses the per-test progress log, but not the summary. + Quiet bool +} + +// Run executes the tests in order and returns one result per test. Tests that +// are not run (because of a failure with FailFast, or an expired context) are +// reported as skipped, so the result list always covers the full playlist. +func (r *Runner) Run(ctx context.Context, tests []Test) []Result { + results := make([]Result, 0, len(tests)) + + for i, test := range tests { + if err := ctx.Err(); err != nil { + results = append(results, skipRemaining(tests[i:], fmt.Errorf("test run aborted: %w", err))...) + break + } + + r.logf("\n%sâ–¶ %s%s: %s\n", colorBold, test.Name(), colorReset, test.Description()) + + start := time.Now() + err := test.Run(ctx, r.Out) + result := Result{Name: test.Name(), Duration: time.Since(start), Err: err} + + result.Status = StatusPassed + if err != nil { + result.Status = StatusFailed + } + + results = append(results, result) + + r.logf("%s %s (%s)\n", test.Name(), result.Status.colored(), formatDuration(result.Duration)) + + if err != nil && r.FailFast { + results = append(results, skipRemaining(tests[i+1:], errors.New("skipped after earlier failure"))...) + break + } + } + + return results +} + +func (r *Runner) logf(format string, args ...any) { + if r.Quiet || r.Out == nil { + return + } + + printf(r.Out, format, args...) +} + +func skipRemaining(tests []Test, reason error) []Result { + skipped := make([]Result, 0, len(tests)) + for _, t := range tests { + skipped = append(skipped, Result{Name: t.Name(), Status: StatusSkipped, Err: reason}) + } + + return skipped +} + +// Summarize writes a table of results followed by a one line tally. +func Summarize(w io.Writer, results []Result) { + var ( + passed, failed, skipped int + total time.Duration + ) + for _, res := range results { + total += res.Duration + switch res.Status { + case StatusPassed: + passed++ + case StatusFailed: + failed++ + default: + skipped++ + } + } + + printf(w, "\n%sTest results%s\n", colorBold, colorReset) + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + for _, res := range results { + detail := "" + if res.Err != nil { + detail = res.Err.Error() + } + + printf(tw, " %s\t%s\t%s\t%s\n", res.Status.colored(), res.Name, formatDuration(res.Duration), detail) + } + //nolint:errcheck // flushing to the command's output stream, nothing to recover from + tw.Flush() + + printf(w, "\n%d test(s): %d passed, %d failed, %d skipped in %s\n", + len(results), passed, failed, skipped, formatDuration(total)) +} + +// Err aggregates the failures of a test run into a single error, or returns +// nil if nothing failed. +func Err(results []Result) error { + var failed []string + + for _, res := range results { + if res.Status == StatusFailed { + failed = append(failed, res.Name) + } + } + + if len(failed) == 0 { + return nil + } + + return fmt.Errorf("%d of %d test(s) failed: %s", len(failed), len(results), strings.Join(failed, ",")) +} + +// printf writes to the report output. Write errors are ignored: the output is +// the operator's terminal, and there is no fallback to report them on. +func printf(w io.Writer, format string, args ...any) { + //nolint:errcheck // see above + fmt.Fprintf(w, format, args...) +} + +func formatDuration(d time.Duration) string { + if d < time.Second { + return d.Round(time.Millisecond).String() + } + + return d.Round(100 * time.Millisecond).String() +} diff --git a/internal/codesphere/testplan/testplan_suite_test.go b/internal/codesphere/testplan/testplan_suite_test.go new file mode 100644 index 000000000..fbe3f880b --- /dev/null +++ b/internal/codesphere/testplan/testplan_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package testplan_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTestplan(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Testplan Suite") +} diff --git a/internal/codesphere/testplan/testplan_test.go b/internal/codesphere/testplan/testplan_test.go new file mode 100644 index 000000000..987988ea2 --- /dev/null +++ b/internal/codesphere/testplan/testplan_test.go @@ -0,0 +1,235 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package testplan_test + +import ( + "bytes" + "context" + "fmt" + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/codesphere/testplan" +) + +// recordingTest records that it ran and returns a fixed error. +type recordingTest struct { + name string + err error + ran *[]string +} + +func (t *recordingTest) Name() string { return t.name } +func (t *recordingTest) Description() string { return t.name + " description" } + +func (t *recordingTest) Run(_ context.Context, out io.Writer) error { + *t.ran = append(*t.ran, t.name) + _, _ = fmt.Fprintf(out, "output of %s\n", t.name) + + return t.err +} + +var _ = Describe("Testplan", func() { + var ( + ran []string + out *bytes.Buffer + passes *recordingTest + fails *recordingTest + second *recordingTest + ) + + newTest := func(name string, err error) *recordingTest { + return &recordingTest{name: name, err: err, ran: &ran} + } + + BeforeEach(func() { + ran = []string{} + out = &bytes.Buffer{} + passes = newTest("passes", nil) + fails = newTest("fails", fmt.Errorf("boom")) + second = newTest("second", nil) + }) + + Describe("Registry", func() { + var registry *testplan.Registry + + BeforeEach(func() { + registry = testplan.NewRegistry(passes, fails, second) + registry.AddPlaylist(testplan.Playlist{ + Name: "default", + Tests: []string{"fails", "passes"}, + }) + }) + + It("lists tests and playlists in registration order", func() { + Expect(registry.TestNames()).To(Equal([]string{"passes", "fails", "second"})) + Expect(registry.PlaylistNames()).To(Equal([]string{"default"})) + }) + + It("selects tests in the requested order", func() { + tests, err := registry.Select([]string{"second", "passes"}) + + Expect(err).NotTo(HaveOccurred()) + Expect(tests).To(HaveLen(2)) + Expect(tests[0].Name()).To(Equal("second")) + Expect(tests[1].Name()).To(Equal("passes")) + }) + + It("ignores duplicates in a selection", func() { + tests, err := registry.Select([]string{"passes", "passes"}) + + Expect(err).NotTo(HaveOccurred()) + Expect(tests).To(HaveLen(1)) + }) + + It("reports unknown test names", func() { + _, err := registry.Select([]string{"passes", "nope"}) + + Expect(err).To(MatchError(ContainSubstring("unknown test(s) nope"))) + Expect(err).To(MatchError(ContainSubstring("passes,fails,second"))) + }) + + It("returns an error for an empty selection", func() { + _, err := registry.Select(nil) + + Expect(err).To(MatchError(ContainSubstring("no tests selected"))) + }) + + It("resolves a playlist to its tests, keeping the playlist order", func() { + tests, err := registry.SelectPlaylist("default") + + Expect(err).NotTo(HaveOccurred()) + Expect(tests[0].Name()).To(Equal("fails")) + Expect(tests[1].Name()).To(Equal("passes")) + }) + + It("reports an unknown playlist", func() { + _, err := registry.SelectPlaylist("nope") + + Expect(err).To(MatchError(ContainSubstring(`unknown playlist "nope"`))) + Expect(err).To(MatchError(ContainSubstring("available playlists are default"))) + }) + + It("reports a playlist that references an unknown test", func() { + registry.AddPlaylist(testplan.Playlist{Name: "broken", Tests: []string{"nope"}}) + + _, err := registry.SelectPlaylist("broken") + + Expect(err).To(MatchError(ContainSubstring(`playlist "broken"`))) + Expect(err).To(MatchError(ContainSubstring("unknown test(s) nope"))) + }) + + It("describes tests and playlists", func() { + registry.Describe(out) + + Expect(out.String()).To(ContainSubstring("passes description")) + Expect(out.String()).To(ContainSubstring("default")) + Expect(out.String()).To(ContainSubstring("[fails, passes]")) + }) + }) + + Describe("Runner", func() { + var runner *testplan.Runner + + BeforeEach(func() { + runner = &testplan.Runner{Out: out} + }) + + It("runs all tests and reports their status", func() { + results := runner.Run(context.Background(), []testplan.Test{passes, fails, second}) + + Expect(ran).To(Equal([]string{"passes", "fails", "second"})) + Expect(results).To(HaveLen(3)) + Expect(results[0].Status).To(Equal(testplan.StatusPassed)) + Expect(results[1].Status).To(Equal(testplan.StatusFailed)) + Expect(results[1].Err).To(MatchError("boom")) + Expect(results[2].Status).To(Equal(testplan.StatusPassed)) + }) + + It("continues after a failure by default", func() { + runner.Run(context.Background(), []testplan.Test{fails, second}) + + Expect(ran).To(Equal([]string{"fails", "second"})) + }) + + It("skips the remaining tests with fail-fast", func() { + runner.FailFast = true + + results := runner.Run(context.Background(), []testplan.Test{fails, second}) + + Expect(ran).To(Equal([]string{"fails"})) + Expect(results).To(HaveLen(2)) + Expect(results[1].Name).To(Equal("second")) + Expect(results[1].Status).To(Equal(testplan.StatusSkipped)) + }) + + It("skips all tests when the context is already done", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + results := runner.Run(ctx, []testplan.Test{passes, second}) + + Expect(ran).To(BeEmpty()) + Expect(results).To(HaveLen(2)) + + for _, res := range results { + Expect(res.Status).To(Equal(testplan.StatusSkipped)) + Expect(res.Err).To(MatchError(ContainSubstring("test run aborted"))) + } + }) + + It("forwards test output and logs progress", func() { + runner.Run(context.Background(), []testplan.Test{passes}) + + Expect(out.String()).To(ContainSubstring("passes description")) + Expect(out.String()).To(ContainSubstring("output of passes")) + Expect(out.String()).To(ContainSubstring("PASS")) + }) + + It("keeps test output but drops progress logging when quiet", func() { + runner.Quiet = true + + runner.Run(context.Background(), []testplan.Test{passes}) + + Expect(out.String()).To(ContainSubstring("output of passes")) + Expect(out.String()).NotTo(ContainSubstring("passes description")) + }) + }) + + Describe("Summarize", func() { + It("lists every result and tallies them", func() { + results := []testplan.Result{ + {Name: "passes", Status: testplan.StatusPassed}, + {Name: "fails", Status: testplan.StatusFailed, Err: fmt.Errorf("boom")}, + {Name: "second", Status: testplan.StatusSkipped}, + } + + testplan.Summarize(out, results) + + Expect(out.String()).To(ContainSubstring("passes")) + Expect(out.String()).To(ContainSubstring("boom")) + Expect(out.String()).To(ContainSubstring("3 test(s): 1 passed, 1 failed, 1 skipped")) + }) + }) + + Describe("Err", func() { + It("returns nil if nothing failed", func() { + Expect(testplan.Err([]testplan.Result{ + {Name: "passes", Status: testplan.StatusPassed}, + {Name: "second", Status: testplan.StatusSkipped}, + })).To(BeNil()) + }) + + It("names the failed tests", func() { + err := testplan.Err([]testplan.Result{ + {Name: "passes", Status: testplan.StatusPassed}, + {Name: "fails", Status: testplan.StatusFailed}, + }) + + Expect(err).To(MatchError("1 of 2 test(s) failed: fails")) + }) + }) +}) diff --git a/internal/github/github.go b/internal/github/github.go index be6cc4b08..540cc5109 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -6,8 +6,6 @@ package github import ( "context" "fmt" - - "github.com/google/go-github/v74/github" ) // GetSSHKeysFromGitHubTeam fetches the public SSH keys of all members of the specified GitHub team and formats them for inclusion in instance metadata. @@ -15,62 +13,20 @@ func GetSSHKeysFromGitHubTeam(client GitHubClient, org, teamSlug string) (string if org == "" || teamSlug == "" { return "", fmt.Errorf("GitHub team slug and org must be specified to fetch SSH keys from GitHub team") } - allKeys := "" - allMembers, err := listAllGitHubTeamMembers(client, org, teamSlug) + members, err := client.GetTeamMemberSSHKeys(context.Background(), org, teamSlug) if err != nil { - return "", fmt.Errorf("failed to list GitHub team members: %w", err) + return "", fmt.Errorf("failed to fetch SSH keys from GitHub team: %w", err) } - fmt.Printf("Found %d members in team '%s'\n", len(allMembers), teamSlug) + fmt.Printf("Found %d members in team '%s'\n", len(members), teamSlug) - for _, user := range allMembers { - username := user.GetLogin() - keys, err := client.ListUserKeys(context.Background(), username) - if err != nil { - fmt.Printf("Could not fetch keys for %s: %v\n", username, err) - continue - } - - for _, key := range keys { - allKeys += fmt.Sprintf("root:%s %sroot\nubuntu:%s %subuntu\n", key.GetKey(), username, key.GetKey(), username) + allKeys := "" + for _, member := range members { + for _, key := range member.Keys { + allKeys += fmt.Sprintf("root:%s %sroot\nubuntu:%s %subuntu\n", key, member.Login, key, member.Login) } } return allKeys, nil } - -// listAllGitHubTeamMembers retrieves all members of the specified GitHub team, handling pagination to ensure all members are fetched. -func listAllGitHubTeamMembers(client GitHubClient, org string, teamSlug string) ([]*github.User, error) { - perPage := 100 - page := 1 - var allMembers []*github.User - - for { - opts := &github.TeamListTeamMembersOptions{ - ListOptions: github.ListOptions{ - Page: page, - PerPage: perPage, - }, - } - - members, err := client.ListTeamMembersBySlug(context.Background(), org, teamSlug, opts) - if err != nil { - return nil, fmt.Errorf("failed to fetch team members from GitHub: %w", err) - } - - if len(members) == 0 { - break - } - - allMembers = append(allMembers, members...) - - if len(members) < perPage { - break - } - - page++ - } - - return allMembers, nil -} diff --git a/internal/github/github_client.go b/internal/github/github_client.go index 0e25e8a8a..860d3c14f 100644 --- a/internal/github/github_client.go +++ b/internal/github/github_client.go @@ -4,39 +4,174 @@ package github import ( + "bytes" "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" - "github.com/google/go-github/v74/github" "golang.org/x/oauth2" ) -// GitHubClient abstracts the GitHub API calls used to fetch team SSH keys. +const githubGraphQLEndpoint = "https://api.github.com/graphql" + +// publicKeysPageSize is how many public SSH keys we request per team member. A user is very +// unlikely to have this many keys; totalCount lets us detect and log the rare case where they do. +const publicKeysPageSize = 20 + +// teamMemberSSHKeysQuery fetches every member of a team together with their public SSH keys in a +// single request. Members are paginated with the $after cursor; publicKeys are fetched in a single +// page of publicKeysPageSize and totalCount is used to detect truncation. +const teamMemberSSHKeysQuery = `query($org: String!, $team: String!, $after: String) { + organization(login: $org) { + team(slug: $team) { + members(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + login + publicKeys(first: 20) { totalCount nodes { key } } + } + } + } + } +}` + +// TeamMemberKeys holds a team member's login and their public SSH keys. +type TeamMemberKeys struct { + Login string + Keys []string +} + +// GitHubClient abstracts the GitHub API call used to fetch team SSH keys. // //mockery:generate: true type GitHubClient interface { - ListTeamMembersBySlug(ctx context.Context, org, teamSlug string, opts *github.TeamListTeamMembersOptions) ([]*github.User, error) - ListUserKeys(ctx context.Context, username string) ([]*github.Key, error) + GetTeamMemberSSHKeys(ctx context.Context, org, teamSlug string) ([]TeamMemberKeys, error) } type RealGitHubClient struct { - client *github.Client + httpClient *http.Client + endpoint string } // NewGitHubClient creates a new RealGitHubClient with the provided OAuth token. -func NewGitHubClient(ctx context.Context, token string) *RealGitHubClient { +func NewGitHubClient(ctx context.Context, token string) (*RealGitHubClient, error) { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) - tc := oauth2.NewClient(ctx, ts) - return &RealGitHubClient{client: github.NewClient(tc)} + return &RealGitHubClient{ + httpClient: oauth2.NewClient(ctx, ts), + endpoint: githubGraphQLEndpoint, + }, nil } -// ListTeamMembersBySlug lists the members of a GitHub team identified by its slug. -func (c *RealGitHubClient) ListTeamMembersBySlug(ctx context.Context, org, teamSlug string, opts *github.TeamListTeamMembersOptions) ([]*github.User, error) { - members, _, err := c.client.Teams.ListTeamMembersBySlug(ctx, org, teamSlug, opts) - return members, err +// graphQLResponse mirrors the shape of the teamMemberSSHKeysQuery response. +type graphQLResponse struct { + Data struct { + Organization struct { + Team struct { + Members struct { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []struct { + Login string `json:"login"` + PublicKeys struct { + TotalCount int `json:"totalCount"` + Nodes []struct { + Key string `json:"key"` + } `json:"nodes"` + } `json:"publicKeys"` + } `json:"nodes"` + } `json:"members"` + } `json:"team"` + } `json:"organization"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// GetTeamMemberSSHKeys fetches all members of the team and their public SSH keys via the GitHub +// GraphQL API, following member pagination until every member has been retrieved. +func (c *RealGitHubClient) GetTeamMemberSSHKeys(ctx context.Context, org, teamSlug string) ([]TeamMemberKeys, error) { + var members []TeamMemberKeys + var after *string + + for { + resp, err := c.queryTeamMembers(ctx, org, teamSlug, after) + if err != nil { + return nil, err + } + + team := resp.Data.Organization.Team + for _, node := range team.Members.Nodes { + if node.PublicKeys.TotalCount > publicKeysPageSize { + fmt.Printf("User %s has %d public keys but only the first %d were fetched\n", + node.Login, node.PublicKeys.TotalCount, publicKeysPageSize) + } + keys := make([]string, 0, len(node.PublicKeys.Nodes)) + for _, k := range node.PublicKeys.Nodes { + keys = append(keys, k.Key) + } + members = append(members, TeamMemberKeys{Login: node.Login, Keys: keys}) + } + + if !team.Members.PageInfo.HasNextPage { + break + } + cursor := team.Members.PageInfo.EndCursor + after = &cursor + } + + return members, nil } -// ListUserKeys lists the public SSH keys of a GitHub user. -func (c *RealGitHubClient) ListUserKeys(ctx context.Context, username string) ([]*github.Key, error) { - keys, _, err := c.client.Users.ListKeys(ctx, username, nil) - return keys, err +// queryTeamMembers executes a single page of the teamMemberSSHKeysQuery. +func (c *RealGitHubClient) queryTeamMembers(ctx context.Context, org, teamSlug string, after *string) (*graphQLResponse, error) { + variables := map[string]any{"org": org, "team": teamSlug} + if after != nil { + variables["after"] = *after + } + + body, err := json.Marshal(map[string]any{"query": teamMemberSSHKeysQuery, "variables": variables}) + if err != nil { + return nil, fmt.Errorf("failed to marshal GraphQL request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create GraphQL request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + httpResp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute GraphQL request: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read GraphQL response: %w", err) + } + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GraphQL request failed with status %d: %s", httpResp.StatusCode, string(respBody)) + } + + var result graphQLResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal GraphQL response: %w", err) + } + if len(result.Errors) > 0 { + msgs := make([]string, len(result.Errors)) + for i, e := range result.Errors { + msgs[i] = e.Message + } + return nil, fmt.Errorf("GraphQL query returned errors: %s", strings.Join(msgs, "; ")) + } + + return &result, nil } diff --git a/internal/github/github_test.go b/internal/github/github_test.go index f4357c848..cefe81630 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -10,8 +10,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" - - gh "github.com/google/go-github/v74/github" ) var _ = Describe("Github", func() { @@ -30,8 +28,9 @@ var _ = Describe("Github", func() { }) It("fetches GitHub team keys", func() { - mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, org, teamSlug, mock.Anything).Return([]*gh.User{{Login: gh.Ptr("alice")}}, nil).Once() - mockGitHubClient.EXPECT().ListUserKeys(mock.Anything, "alice").Return([]*gh.Key{{Key: gh.Ptr("ssh-rsa AAALICE...")}}, nil).Once() + mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, org, teamSlug).Return([]github.TeamMemberKeys{ + {Login: "alice", Keys: []string{"ssh-rsa AAALICE..."}}, + }, nil).Once() keys, err := github.GetSSHKeysFromGitHubTeam(mockGitHubClient, org, teamSlug) Expect(err).ToNot(HaveOccurred()) @@ -39,20 +38,21 @@ var _ = Describe("Github", func() { Expect(keys).To(ContainSubstring("ubuntu:ssh-rsa AAALICE... alice")) }) - Context("when fetching team members fails", func() { + Context("when fetching team member keys fails", func() { It("returns an error", func() { - mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, org, teamSlug, mock.Anything).Return(nil, fmt.Errorf("GitHub API error")).Once() + mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, org, teamSlug).Return(nil, fmt.Errorf("GitHub API error")).Once() keys, err := github.GetSSHKeysFromGitHubTeam(mockGitHubClient, org, teamSlug) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to list GitHub team members")) + Expect(err.Error()).To(ContainSubstring("failed to fetch SSH keys from GitHub team")) Expect(keys).To(BeEmpty()) }) }) - Context("when fetching user keys fails", func() { - It("skips the user and continues", func() { - mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, org, teamSlug, mock.Anything).Return([]*gh.User{{Login: gh.Ptr("alice")}}, nil).Once() - mockGitHubClient.EXPECT().ListUserKeys(mock.Anything, "alice").Return(nil, fmt.Errorf("GitHub API error")).Once() + Context("when a member has no keys", func() { + It("skips the member and continues", func() { + mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, org, teamSlug).Return([]github.TeamMemberKeys{ + {Login: "alice", Keys: nil}, + }, nil).Once() keys, err := github.GetSSHKeysFromGitHubTeam(mockGitHubClient, org, teamSlug) Expect(err).ToNot(HaveOccurred()) Expect(keys).To(BeEmpty()) @@ -61,31 +61,24 @@ var _ = Describe("Github", func() { Context("when team has no members", func() { It("returns an empty string", func() { - mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, org, teamSlug, mock.Anything).Return([]*gh.User{}, nil).Once() + mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, org, teamSlug).Return([]github.TeamMemberKeys{}, nil).Once() keys, err := github.GetSSHKeysFromGitHubTeam(mockGitHubClient, org, teamSlug) Expect(err).ToNot(HaveOccurred()) Expect(keys).To(BeEmpty()) }) }) - Context("when team has more than 100 members", func() { - It("handles pagination correctly", func() { - // Simulate 150 members to trigger pagination - membersPage1 := make([]*gh.User, 100) - for i := 0; i < 100; i++ { - membersPage1[i] = &gh.User{Login: gh.Ptr(fmt.Sprintf("user%d", i+1))} - } - membersPage2 := make([]*gh.User, 50) - for i := 0; i < 50; i++ { - membersPage2[i] = &gh.User{Login: gh.Ptr(fmt.Sprintf("user%d", i+101))} + Context("when the team has many members", func() { + It("formats keys for every member", func() { + members := make([]github.TeamMemberKeys, 150) + for i := 0; i < 150; i++ { + members[i] = github.TeamMemberKeys{ + Login: fmt.Sprintf("user%d", i+1), + Keys: []string{fmt.Sprintf("ssh-rsa AAAUSER%d...", i+1)}, + } } - mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, org, teamSlug, mock.Anything).Return(membersPage1, nil).Once() - mockGitHubClient.EXPECT().ListTeamMembersBySlug(mock.Anything, org, teamSlug, mock.Anything).Return(membersPage2, nil).Once() - - for i := 1; i <= 150; i++ { - mockGitHubClient.EXPECT().ListUserKeys(mock.Anything, fmt.Sprintf("user%d", i)).Return([]*gh.Key{{Key: gh.Ptr(fmt.Sprintf("ssh-rsa AAAUSER%d...", i))}}, nil).Once() - } + mockGitHubClient.EXPECT().GetTeamMemberSSHKeys(mock.Anything, org, teamSlug).Return(members, nil).Once() keys, err := github.GetSSHKeysFromGitHubTeam(mockGitHubClient, org, teamSlug) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/github/mocks.go b/internal/github/mocks.go index 228efba51..a8e952879 100644 --- a/internal/github/mocks.go +++ b/internal/github/mocks.go @@ -6,7 +6,6 @@ package github import ( "context" - "github.com/google/go-github/v74/github" mock "github.com/stretchr/testify/mock" ) @@ -37,49 +36,48 @@ func (_m *MockGitHubClient) EXPECT() *MockGitHubClient_Expecter { return &MockGitHubClient_Expecter{mock: &_m.Mock} } -// ListTeamMembersBySlug provides a mock function for the type MockGitHubClient -func (_mock *MockGitHubClient) ListTeamMembersBySlug(ctx context.Context, org string, teamSlug string, opts *github.TeamListTeamMembersOptions) ([]*github.User, error) { - ret := _mock.Called(ctx, org, teamSlug, opts) +// GetTeamMemberSSHKeys provides a mock function for the type MockGitHubClient +func (_mock *MockGitHubClient) GetTeamMemberSSHKeys(ctx context.Context, org string, teamSlug string) ([]TeamMemberKeys, error) { + ret := _mock.Called(ctx, org, teamSlug) if len(ret) == 0 { - panic("no return value specified for ListTeamMembersBySlug") + panic("no return value specified for GetTeamMemberSSHKeys") } - var r0 []*github.User + var r0 []TeamMemberKeys var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *github.TeamListTeamMembersOptions) ([]*github.User, error)); ok { - return returnFunc(ctx, org, teamSlug, opts) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]TeamMemberKeys, error)); ok { + return returnFunc(ctx, org, teamSlug) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *github.TeamListTeamMembersOptions) []*github.User); ok { - r0 = returnFunc(ctx, org, teamSlug, opts) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []TeamMemberKeys); ok { + r0 = returnFunc(ctx, org, teamSlug) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*github.User) + r0 = ret.Get(0).([]TeamMemberKeys) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *github.TeamListTeamMembersOptions) error); ok { - r1 = returnFunc(ctx, org, teamSlug, opts) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, org, teamSlug) } else { r1 = ret.Error(1) } return r0, r1 } -// MockGitHubClient_ListTeamMembersBySlug_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTeamMembersBySlug' -type MockGitHubClient_ListTeamMembersBySlug_Call struct { +// MockGitHubClient_GetTeamMemberSSHKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTeamMemberSSHKeys' +type MockGitHubClient_GetTeamMemberSSHKeys_Call struct { *mock.Call } -// ListTeamMembersBySlug is a helper method to define mock.On call +// GetTeamMemberSSHKeys is a helper method to define mock.On call // - ctx context.Context // - org string // - teamSlug string -// - opts *github.TeamListTeamMembersOptions -func (_e *MockGitHubClient_Expecter) ListTeamMembersBySlug(ctx any, org any, teamSlug any, opts any) *MockGitHubClient_ListTeamMembersBySlug_Call { - return &MockGitHubClient_ListTeamMembersBySlug_Call{Call: _e.mock.On("ListTeamMembersBySlug", ctx, org, teamSlug, opts)} +func (_e *MockGitHubClient_Expecter) GetTeamMemberSSHKeys(ctx any, org any, teamSlug any) *MockGitHubClient_GetTeamMemberSSHKeys_Call { + return &MockGitHubClient_GetTeamMemberSSHKeys_Call{Call: _e.mock.On("GetTeamMemberSSHKeys", ctx, org, teamSlug)} } -func (_c *MockGitHubClient_ListTeamMembersBySlug_Call) Run(run func(ctx context.Context, org string, teamSlug string, opts *github.TeamListTeamMembersOptions)) *MockGitHubClient_ListTeamMembersBySlug_Call { +func (_c *MockGitHubClient_GetTeamMemberSSHKeys_Call) Run(run func(ctx context.Context, org string, teamSlug string)) *MockGitHubClient_GetTeamMemberSSHKeys_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -93,94 +91,21 @@ func (_c *MockGitHubClient_ListTeamMembersBySlug_Call) Run(run func(ctx context. if args[2] != nil { arg2 = args[2].(string) } - var arg3 *github.TeamListTeamMembersOptions - if args[3] != nil { - arg3 = args[3].(*github.TeamListTeamMembersOptions) - } run( arg0, arg1, arg2, - arg3, - ) - }) - return _c -} - -func (_c *MockGitHubClient_ListTeamMembersBySlug_Call) Return(users []*github.User, err error) *MockGitHubClient_ListTeamMembersBySlug_Call { - _c.Call.Return(users, err) - return _c -} - -func (_c *MockGitHubClient_ListTeamMembersBySlug_Call) RunAndReturn(run func(ctx context.Context, org string, teamSlug string, opts *github.TeamListTeamMembersOptions) ([]*github.User, error)) *MockGitHubClient_ListTeamMembersBySlug_Call { - _c.Call.Return(run) - return _c -} - -// ListUserKeys provides a mock function for the type MockGitHubClient -func (_mock *MockGitHubClient) ListUserKeys(ctx context.Context, username string) ([]*github.Key, error) { - ret := _mock.Called(ctx, username) - - if len(ret) == 0 { - panic("no return value specified for ListUserKeys") - } - - var r0 []*github.Key - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]*github.Key, error)); ok { - return returnFunc(ctx, username) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) []*github.Key); ok { - r0 = returnFunc(ctx, username) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*github.Key) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = returnFunc(ctx, username) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockGitHubClient_ListUserKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListUserKeys' -type MockGitHubClient_ListUserKeys_Call struct { - *mock.Call -} - -// ListUserKeys is a helper method to define mock.On call -// - ctx context.Context -// - username string -func (_e *MockGitHubClient_Expecter) ListUserKeys(ctx any, username any) *MockGitHubClient_ListUserKeys_Call { - return &MockGitHubClient_ListUserKeys_Call{Call: _e.mock.On("ListUserKeys", ctx, username)} -} - -func (_c *MockGitHubClient_ListUserKeys_Call) Run(run func(ctx context.Context, username string)) *MockGitHubClient_ListUserKeys_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, ) }) return _c } -func (_c *MockGitHubClient_ListUserKeys_Call) Return(keys []*github.Key, err error) *MockGitHubClient_ListUserKeys_Call { - _c.Call.Return(keys, err) +func (_c *MockGitHubClient_GetTeamMemberSSHKeys_Call) Return(teamMemberKeyss []TeamMemberKeys, err error) *MockGitHubClient_GetTeamMemberSSHKeys_Call { + _c.Call.Return(teamMemberKeyss, err) return _c } -func (_c *MockGitHubClient_ListUserKeys_Call) RunAndReturn(run func(ctx context.Context, username string) ([]*github.Key, error)) *MockGitHubClient_ListUserKeys_Call { +func (_c *MockGitHubClient_GetTeamMemberSSHKeys_Call) RunAndReturn(run func(ctx context.Context, org string, teamSlug string) ([]TeamMemberKeys, error)) *MockGitHubClient_GetTeamMemberSSHKeys_Call { _c.Call.Return(run) return _c } diff --git a/internal/installer/argocd/install_and_apps.go b/internal/installer/argocd/install_and_apps.go index 035612f4c..6d11a524c 100644 --- a/internal/installer/argocd/install_and_apps.go +++ b/internal/installer/argocd/install_and_apps.go @@ -6,15 +6,71 @@ package argocd import ( "context" "fmt" + "time" + argov1alpha1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/bom" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/secrets" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/util" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" ) +const ( + applicationReadyTimeout = 30 * time.Minute + applicationReadyPollInterval = 5 * time.Second +) + +// LogFunc records formatted application readiness messages. +type LogFunc func(format string, args ...interface{}) + +// WaitForApplicationHealthy waits until Argo CD has compared the requested target revision +// and reports the Application as healthy and synced. +func WaitForApplicationHealthy(ctx context.Context, kubeClient client.Client, name, targetRevision string, logf LogFunc) error { + if logf == nil { + logf = func(string, ...interface{}) {} + } + + logf("Waiting for ArgoCD Application %q to become healthy and synced (timeout %s)", name, applicationReadyTimeout) + + lastHealth, lastSync, lastTargetRevision := "", "", "" + + err := wait.PollUntilContextTimeout(ctx, applicationReadyPollInterval, applicationReadyTimeout, true, func(ctx context.Context) (bool, error) { + app := &argov1alpha1.Application{} + if err := kubeClient.Get(ctx, client.ObjectKey{Name: name, Namespace: DefaultNamespace}, app); err != nil { + if !apierrors.IsNotFound(err) { + return false, fmt.Errorf("failed to read ArgoCD Application %q: %w", name, err) + } + + logf("Waiting for ArgoCD Application %q: failed to read status: %v", name, err) + + return false, nil + } + + lastHealth, lastSync = string(app.Status.Health.Status), string(app.Status.Sync.Status) + + lastTargetRevision = app.Status.Sync.ComparedTo.Source.TargetRevision + if lastTargetRevision == targetRevision && app.Status.Health.Status == "Healthy" && app.Status.Sync.Status == argov1alpha1.SyncStatusCodeSynced { + logf("ArgoCD Application %q is healthy and synced", name) + return true, nil + } + + logf("Waiting for ArgoCD Application %q (target-revision=%q/%q, health=%q, sync=%q)", name, lastTargetRevision, targetRevision, lastHealth, lastSync) + + return false, nil + }) + if err != nil { + return fmt.Errorf("timed out waiting for ArgoCD Application %q to become healthy and synced at target revision %q (observed target revision=%q, health=%q, sync=%q): %w", name, targetRevision, lastTargetRevision, lastHealth, lastSync, err) + } + + return nil +} + // InstallerAPI is implemented by the concrete ArgoCD chart installer. type InstallerAPI interface { Install() error @@ -68,13 +124,17 @@ func (i *AppInstaller) SyncVaultSecret(ctx context.Context) error { // InstallPCApps creates or updates the pc-applications app-of-apps ArgoCD // Application using the chart version from the supplied installer BOM. -func (i *AppInstaller) InstallPCApps(ctx context.Context, bomPath string) error { +func (i *AppInstaller) InstallPCApps(ctx context.Context, bomConfig *bom.Config) error { + // Values derived from the install config form the base; an explicit pcApps block in + // config.yaml wins over them, and the --pc-apps-values files win over both. + values := util.DeepMergeMaps(installer.OpenFgaPcAppsValues(&i.cfg.Config, i.cfg.Vault), i.cfg.Config.PcApps) + pcApps, err := installer.NewPcAppsFromBom( i.cfg.KubeClient, - bomPath, + bomConfig, DefaultNamespace, i.cfg.PCAppsValues, - i.cfg.Config.PcApps, + values, ) if err != nil { return fmt.Errorf("failed to initialize pc-apps installer: %w", err) diff --git a/internal/installer/argocd/install_and_apps_test.go b/internal/installer/argocd/install_and_apps_test.go index 66bbec087..7e6814c45 100644 --- a/internal/installer/argocd/install_and_apps_test.go +++ b/internal/installer/argocd/install_and_apps_test.go @@ -13,6 +13,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/argocd" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -97,7 +98,7 @@ users: Expect(err).ToNot(HaveOccurred()) vaultPath := filepath.Join(secretsDir, "prod.vault.yaml") - Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) + Expect(sops.EncryptFile(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) config := files.RootConfig{ Secrets: files.SecretsConfig{ @@ -105,7 +106,7 @@ users: }, } - loadedVault, restConfig, err := installer.VaultAndRESTConfig("", ageKeyPath, config) + loadedVault, restConfig, err := installer.VaultAndRESTConfig("", ageKeyPath, string(vault.TypeSOPS), config) Expect(err).ToNot(HaveOccurred()) Expect(loadedVault).ToNot(BeNil()) Expect(restConfig).ToNot(BeNil()) diff --git a/internal/installer/bom/bom.go b/internal/installer/bom/bom.go index a532acb79..d631fc7a2 100644 --- a/internal/installer/bom/bom.go +++ b/internal/installer/bom/bom.go @@ -7,6 +7,8 @@ import ( "encoding/json" "fmt" "os" + "sort" + "strings" "github.com/distribution/reference" ) @@ -17,6 +19,36 @@ type Config struct { Migrations MigrationsConfig `json:"migrations"` } +// GetOCIArtifacts returns every container image and OCI Helm chart referenced +// by the BOM. Duplicate references are returned only once and the result is +// sorted so callers can present a stable transfer plan. +func (b *Config) GetOCIArtifacts() []string { + artifacts := map[string]struct{}{} + + for _, component := range b.Components { + for _, image := range component.ContainerImages { + if image != "" { + artifacts[image] = struct{}{} + } + } + + for _, file := range component.Files { + if file.OciRef != "" { + artifacts[file.OciRef] = struct{}{} + } + } + } + + result := make([]string, 0, len(artifacts)) + for artifact := range artifacts { + result = append(result, artifact) + } + + sort.Strings(result) + + return result +} + // ComponentConfig represents a component in the BOM. type ComponentConfig struct { ContainerImages map[string]string `json:"containerImages,omitempty"` @@ -69,8 +101,14 @@ func Parse(filePath string) (*Config, error) { // components["pc-applications"].files["chart"].ociRef. // Returns (nil, false) when the component is absent, the chart file entry is // missing, or the ociRef has no recognisable tag. -func (b *Config) GetPCApps() (reference.Tagged, bool) { - comp, ok := b.Components["pc-applications"] +func (b *Config) GetPCApps() (reference.NamedTagged, bool) { + return b.GetChart("pc-applications") +} + +// GetChart returns the tagged OCI chart reference stored in the named +// component's files["chart"].ociRef entry. +func (b *Config) GetChart(component string) (reference.NamedTagged, bool) { + comp, ok := b.Components[component] if !ok { return nil, false } @@ -78,11 +116,13 @@ func (b *Config) GetPCApps() (reference.Tagged, bool) { if !ok || chart.OciRef == "" { return nil, false } - ref, err := reference.ParseNormalizedNamed(chart.OciRef) + + ref, err := reference.ParseNormalizedNamed(strings.TrimPrefix(chart.OciRef, "oci://")) if err != nil { return nil, false } - tagged, ok := ref.(reference.Tagged) + + tagged, ok := ref.(reference.NamedTagged) if !ok { return nil, false } diff --git a/internal/installer/bom/bom_test.go b/internal/installer/bom/bom_test.go index 0576388bd..b871ed737 100644 --- a/internal/installer/bom/bom_test.go +++ b/internal/installer/bom/bom_test.go @@ -213,4 +213,55 @@ var _ = Describe("Bom", func() { Expect(images).To(BeEmpty()) }) }) + + Describe("GetChart", func() { + It("returns a tagged OCI chart for the requested component", func() { + cfg := &bom.Config{Components: map[string]bom.ComponentConfig{ + "argocd": {Files: map[string]bom.FileRef{ + "chart": {OciRef: "oci://ghcr.io/codesphere-cloud/charts/argocd:1.2.3"}, + }}, + }} + + chart, ok := cfg.GetChart("argocd") + Expect(ok).To(BeTrue()) + Expect(chart.Name()).To(Equal("ghcr.io/codesphere-cloud/charts/argocd")) + Expect(chart.Tag()).To(Equal("1.2.3")) + }) + + It("returns false when the component has no tagged chart", func() { + cfg := &bom.Config{Components: map[string]bom.ComponentConfig{}} + _, ok := cfg.GetChart("argocd") + Expect(ok).To(BeFalse()) + }) + }) + + Describe("GetOCIArtifacts", func() { + It("returns sorted unique container images and OCI Helm charts from all components", func() { + cfg := &bom.Config{Components: map[string]bom.ComponentConfig{ + "codesphere": { + ContainerImages: map[string]string{ + "api": "ghcr.io/codesphere/api:v1", + }, + Files: map[string]bom.FileRef{ + "chart": {OciRef: "oci://ghcr.io/codesphere/charts/codesphere:v1"}, + }, + }, + "dependency": { + ContainerImages: map[string]string{ + "duplicate": "ghcr.io/codesphere/api:v1", + "redis": "docker.io/library/redis:7", + }, + Files: map[string]bom.FileRef{ + "not-oci": {SrcUrl: "https://example.com/file.tgz"}, + }, + }, + }} + + Expect(cfg.GetOCIArtifacts()).To(Equal([]string{ + "docker.io/library/redis:7", + "ghcr.io/codesphere/api:v1", + "oci://ghcr.io/codesphere/charts/codesphere:v1", + })) + }) + }) }) diff --git a/internal/installer/cluster_admin.go b/internal/installer/cluster_admin.go index 448d07a65..5642d3714 100644 --- a/internal/installer/cluster_admin.go +++ b/internal/installer/cluster_admin.go @@ -31,21 +31,25 @@ func ResolveVaultPath(vaultPath string, config files.RootConfig) (string, error) return filepath.Join(config.Secrets.BaseDir, "prod.vault.yaml"), nil } -// VaultAndRESTConfig loads the vault at vaultPath (or the config's secrets -// baseDir fallback) and builds a Kubernetes REST config from the kubeconfig -// stored in it. -func VaultAndRESTConfig(vaultPath, privKey string, cfg files.RootConfig) (*files.InstallVault, *rest.Config, error) { +// VaultAndRESTConfig loads the selected vault implementation and builds +// a Kubernetes REST config from its kubeconfig secret. +func VaultAndRESTConfig(vaultPath, privKey, vaultType string, cfg files.RootConfig) (*files.InstallVault, *rest.Config, error) { resolvedPath, err := ResolveVaultPath(vaultPath, cfg) if err != nil { return nil, nil, err } - vault, err := vault.LoadVaultData(resolvedPath, privKey) + store, err := vault.NewFromString(vaultType, vault.Options{Path: resolvedPath, AgeKey: privKey}) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize vault backend: %w", err) + } + + data, err := store.Load() if err != nil { return nil, nil, fmt.Errorf("failed to load vault %s: %w", resolvedPath, err) } - kubeConfigContent, err := kubeConfigContentFromVault(vault) + kubeConfigContent, err := kubeConfigContentFromVault(data) if err != nil { return nil, nil, err } @@ -55,7 +59,7 @@ func VaultAndRESTConfig(vaultPath, privKey string, cfg files.RootConfig) (*files return nil, nil, fmt.Errorf("failed to load kubernetes config from vault: %w", err) } - return vault, restConfig, nil + return data, restConfig, nil } func kubeConfigContentFromVault(vault *files.InstallVault) (string, error) { @@ -75,13 +79,13 @@ func kubeConfigContentFromVault(vault *files.InstallVault) (string, error) { // codesphere.clusterAdminEmail to the cluster-admin-email secret before the // platform is installed, so the auth-service finds it on first start. // It is a no-op when the config does not set an email. -func EnsureClusterAdminSecret(ctx context.Context, vaultPath, privKey string, cfg files.RootConfig) error { +func EnsureClusterAdminSecret(ctx context.Context, vaultPath, privKey, vaultType string, cfg files.RootConfig) error { email := cfg.Codesphere.ClusterAdminEmail if email == "" { return nil } - _, restConfig, err := VaultAndRESTConfig(vaultPath, privKey, cfg) + _, restConfig, err := VaultAndRESTConfig(vaultPath, privKey, vaultType, cfg) if err != nil { return err } diff --git a/internal/installer/cluster_admin_test.go b/internal/installer/cluster_admin_test.go index d30e618ba..8dd2c0920 100644 --- a/internal/installer/cluster_admin_test.go +++ b/internal/installer/cluster_admin_test.go @@ -16,6 +16,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" ) const testKubeConfig = `apiVersion: v1 @@ -56,7 +57,7 @@ func writeVaultFile(dir string, installVault *files.InstallVault) (vaultPath, ag Expect(err).ToNot(HaveOccurred()) vaultPath = filepath.Join(dir, "prod.vault.yaml") - Expect(vault.EncryptFileWithSOPS(plaintextPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) + Expect(sops.EncryptFile(plaintextPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) return vaultPath, ageKeyPath } @@ -110,7 +111,7 @@ var _ = Describe("VaultAndRESTConfig", func() { It("loads the vault and builds a REST config from the kubeconfig secret", func() { vaultPath, ageKeyPath := writeVaultFile(GinkgoT().TempDir(), vaultWithKubeConfig()) - vault, restConfig, err := installer.VaultAndRESTConfig(vaultPath, ageKeyPath, files.RootConfig{}) + vault, restConfig, err := installer.VaultAndRESTConfig(vaultPath, ageKeyPath, string(vault.TypeSOPS), files.RootConfig{}) Expect(err).ToNot(HaveOccurred()) Expect(vault).ToNot(BeNil()) Expect(vault.GetSecret(files.SecretKubeConfig)).ToNot(BeNil()) @@ -123,7 +124,7 @@ var _ = Describe("VaultAndRESTConfig", func() { secretsDir := GinkgoT().TempDir() _, ageKeyPath := writeVaultFile(secretsDir, vaultWithKubeConfig()) - vault, restConfig, err := installer.VaultAndRESTConfig("", ageKeyPath, files.RootConfig{ + vault, restConfig, err := installer.VaultAndRESTConfig("", ageKeyPath, string(vault.TypeSOPS), files.RootConfig{ Secrets: files.SecretsConfig{BaseDir: secretsDir}, }) Expect(err).ToNot(HaveOccurred()) @@ -132,14 +133,15 @@ var _ = Describe("VaultAndRESTConfig", func() { }) It("fails when the vault path cannot be resolved", func() { - _, _, err := installer.VaultAndRESTConfig("", "", files.RootConfig{}) + _, _, err := installer.VaultAndRESTConfig("", "", string(vault.TypeSOPS), files.RootConfig{}) Expect(err).To(MatchError(ContainSubstring("vault path is not set"))) }) It("fails when the vault file does not exist", func() { - vaultPath := filepath.Join(GinkgoT().TempDir(), "missing.vault.yaml") + tempDir := GinkgoT().TempDir() + vaultPath := filepath.Join(tempDir, "missing.vault.yaml") - _, _, err := installer.VaultAndRESTConfig(vaultPath, "", files.RootConfig{}) + _, _, err := installer.VaultAndRESTConfig(vaultPath, filepath.Join(tempDir, "unused-age-key"), string(vault.TypeSOPS), files.RootConfig{}) Expect(err).To(MatchError(ContainSubstring("failed to load vault"))) }) @@ -153,7 +155,7 @@ var _ = Describe("VaultAndRESTConfig", func() { }, }) - _, _, err := installer.VaultAndRESTConfig(vaultPath, ageKeyPath, files.RootConfig{}) + _, _, err := installer.VaultAndRESTConfig(vaultPath, ageKeyPath, string(vault.TypeSOPS), files.RootConfig{}) Expect(err).To(MatchError(ContainSubstring("kubeconfig not found in vault"))) }) @@ -167,7 +169,7 @@ var _ = Describe("VaultAndRESTConfig", func() { }, }) - _, _, err := installer.VaultAndRESTConfig(vaultPath, ageKeyPath, files.RootConfig{}) + _, _, err := installer.VaultAndRESTConfig(vaultPath, ageKeyPath, string(vault.TypeSOPS), files.RootConfig{}) Expect(err).To(MatchError(ContainSubstring("kubeconfig not found in vault"))) }) @@ -181,7 +183,7 @@ var _ = Describe("VaultAndRESTConfig", func() { }, }) - _, _, err := installer.VaultAndRESTConfig(vaultPath, ageKeyPath, files.RootConfig{}) + _, _, err := installer.VaultAndRESTConfig(vaultPath, ageKeyPath, string(vault.TypeSOPS), files.RootConfig{}) Expect(err).To(MatchError(ContainSubstring("failed to load kubernetes config from vault"))) }) }) @@ -190,7 +192,7 @@ var _ = Describe("EnsureClusterAdminSecret", func() { It("is a no-op when the config does not set a cluster admin email", func() { // No vault path or secrets baseDir: reaching the vault loading would fail, // so a nil result proves the email check short-circuits first. - err := installer.EnsureClusterAdminSecret(context.Background(), "", "", files.RootConfig{}) + err := installer.EnsureClusterAdminSecret(context.Background(), "", "", string(vault.TypeSOPS), files.RootConfig{}) Expect(err).ToNot(HaveOccurred()) }) @@ -199,17 +201,18 @@ var _ = Describe("EnsureClusterAdminSecret", func() { Codesphere: files.CodesphereConfig{ClusterAdminEmail: "admin@codesphere.com"}, } - err := installer.EnsureClusterAdminSecret(context.Background(), "", "", cfg) + err := installer.EnsureClusterAdminSecret(context.Background(), "", "", string(vault.TypeSOPS), cfg) Expect(err).To(MatchError(ContainSubstring("vault path is not set"))) }) It("fails when the vault file does not exist", func() { - vaultPath := filepath.Join(GinkgoT().TempDir(), "missing.vault.yaml") + tempDir := GinkgoT().TempDir() + vaultPath := filepath.Join(tempDir, "missing.vault.yaml") cfg := files.RootConfig{ Codesphere: files.CodesphereConfig{ClusterAdminEmail: "admin@codesphere.com"}, } - err := installer.EnsureClusterAdminSecret(context.Background(), vaultPath, "", cfg) + err := installer.EnsureClusterAdminSecret(context.Background(), vaultPath, filepath.Join(tempDir, "unused-age-key"), string(vault.TypeSOPS), cfg) Expect(err).To(MatchError(ContainSubstring("failed to load vault"))) }) }) diff --git a/internal/installer/codesphere.go b/internal/installer/codesphere.go index c61b1fa72..1a12c507e 100644 --- a/internal/installer/codesphere.go +++ b/internal/installer/codesphere.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/prompt" "github.com/codesphere-cloud/oms/internal/system" "github.com/codesphere-cloud/oms/internal/util" ) @@ -401,9 +402,9 @@ func (ci *CodesphereInstaller) installerCommandArgs(pm PackageManager, config fi sort.Strings(executedSteps) - prompt := NewPrompter(!ci.AutoApprove) + prompter := prompt.NewPrompter(!ci.AutoApprove) msg := fmt.Sprintf("The following steps will be executed: %s. Type \"yes\" to continue.", strings.Join(executedSteps, ", ")) - if prompt.String(msg, "yes") != "yes" { + if prompter.String(msg, "yes") != "yes" { return nil, fmt.Errorf("installation aborted") } diff --git a/internal/installer/config_generator_collector.go b/internal/installer/config_generator_collector.go index 8d48eb92a..370ed5697 100644 --- a/internal/installer/config_generator_collector.go +++ b/internal/installer/config_generator_collector.go @@ -8,10 +8,11 @@ import ( "log" "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/prompt" ) func (g *InstallConfig) CollectInteractively() error { - prompter := NewPrompter(true) + prompter := prompt.NewPrompter(true) g.collectDatacenterConfig(prompter) g.collectRegistryConfig(prompter) @@ -26,20 +27,20 @@ func (g *InstallConfig) CollectInteractively() error { return nil } -func (g *InstallConfig) collectString(prompter *Prompter, prompt, defaultVal string) string { - return prompter.String(prompt, defaultVal) +func (g *InstallConfig) collectString(prompter prompt.Prompter, question, defaultVal string) string { + return prompter.String(question, defaultVal) } -func (g *InstallConfig) collectInt(prompter *Prompter, prompt string, defaultVal int) int { - return prompter.Int(prompt, defaultVal) +func (g *InstallConfig) collectInt(prompter prompt.Prompter, question string, defaultVal int) int { + return prompter.Int(question, defaultVal) } -func (g *InstallConfig) collectStringSlice(prompter *Prompter, prompt string, defaultVal []string) []string { - return prompter.StringSlice(prompt, defaultVal) +func (g *InstallConfig) collectStringSlice(prompter prompt.Prompter, question string, defaultVal []string) []string { + return prompter.StringSlice(question, defaultVal) } -func (g *InstallConfig) collectChoice(prompter *Prompter, prompt string, options []string, defaultVal string) string { - return prompter.Choice(prompt, options, defaultVal) +func (g *InstallConfig) collectChoice(prompter prompt.Prompter, question string, options []string, defaultVal string) string { + return prompter.Choice(question, options, defaultVal) } func k8sNodesToStringSlice(nodes []files.K8sNode) []string { @@ -58,7 +59,7 @@ func stringSliceToK8sNodes(ips []string) []files.K8sNode { return nodes } -func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { +func (g *InstallConfig) collectDatacenterConfig(prompter prompt.Prompter) { log.Println("=== Datacenter Configuration ===") g.Config.Datacenter.ID = g.collectInt(prompter, "Datacenter ID", g.Config.Datacenter.ID) g.Config.Datacenter.Name = g.collectString(prompter, "Datacenter name", g.Config.Datacenter.Name) @@ -67,7 +68,7 @@ func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { g.Config.Secrets.BaseDir = g.collectString(prompter, "Secrets base directory", "/root/secrets") } -func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { +func (g *InstallConfig) collectRegistryConfig(prompter prompt.Prompter) { log.Println("\n=== Container Registry Configuration ===") g.Config.Registry.Server = g.collectString(prompter, "Container registry server (e.g., ghcr.io, leave empty to skip)", "") if g.Config.Registry.Server != "" { @@ -76,7 +77,7 @@ func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { +func (g *InstallConfig) collectPostgresConfig(prompter prompt.Prompter) { log.Println("\n=== PostgreSQL Configuration ===") g.Config.Postgres.Mode = g.collectChoice(prompter, "PostgreSQL setup", []string{"install", "external"}, "install") @@ -110,7 +111,7 @@ func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectCephConfig(prompter *Prompter) { +func (g *InstallConfig) collectCephConfig(prompter prompt.Prompter) { log.Println("\n=== Ceph Configuration ===") g.Config.Ceph.NodesSubnet = g.collectString(prompter, "Ceph nodes subnet (CIDR)", "10.53.101.0/24") @@ -132,7 +133,7 @@ func (g *InstallConfig) collectCephConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { +func (g *InstallConfig) collectK8sConfig(prompter prompt.Prompter) { log.Println("\n=== Kubernetes Configuration ===") g.Config.Kubernetes.ManagedByCodesphere = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", g.Config.Kubernetes.ManagedByCodesphere) @@ -163,7 +164,7 @@ func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectGatewayConfig(prompter *Prompter) { +func (g *InstallConfig) collectGatewayConfig(prompter prompt.Prompter) { log.Println("\n=== Cluster Gateway Configuration ===") g.Config.Cluster.Gateway.ServiceType = g.collectChoice(prompter, "Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") if g.Config.Cluster.Gateway.ServiceType == "ExternalIP" { @@ -176,27 +177,34 @@ func (g *InstallConfig) collectGatewayConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { +func (g *InstallConfig) collectMetalLBConfig(prompter prompt.Prompter) { log.Println("\n=== MetalLB Configuration (Optional) ===") - g.Config.MetalLB.Enabled = prompter.Bool("Enable MetalLB", g.Config.MetalLB.Enabled) + if g.Config.Cluster.MetalLB == nil { + g.Config.Cluster.MetalLB = &files.MetalLBConfig{} + } + + g.Config.Cluster.MetalLB.Enabled = prompter.Bool("Enable MetalLB", g.Config.Cluster.MetalLB.Enabled) - if g.Config.MetalLB.Enabled { - defaultNumPools := len(g.Config.MetalLB.Pools) + if g.Config.Cluster.MetalLB.Enabled { + defaultNumPools := len(g.Config.Cluster.MetalLB.Pools) if defaultNumPools == 0 { defaultNumPools = 1 } numPools := prompter.Int("Number of MetalLB IP pools", defaultNumPools) - g.Config.MetalLB.Pools = make([]files.MetalLBPoolDef, numPools) + existingPools := g.Config.Cluster.MetalLB.Pools + + g.Config.Cluster.MetalLB.Pools = make([]files.MetalLBPoolDef, numPools) for i := 0; i < numPools; i++ { log.Printf("\nMetalLB Pool %d:\n", i+1) defaultName := fmt.Sprintf("pool-%d", i+1) var defaultIPs []string - if i < len(g.Config.MetalLB.Pools) { - defaultName = g.Config.MetalLB.Pools[i].Name - defaultIPs = g.Config.MetalLB.Pools[i].IPAddresses + + if i < len(existingPools) { + defaultName = existingPools[i].Name + defaultIPs = existingPools[i].IPAddresses } if len(defaultIPs) == 0 { defaultIPs = []string{"10.10.10.100-10.10.10.200"} @@ -204,7 +212,7 @@ func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { poolName := prompter.String(" Pool name", defaultName) poolIPs := prompter.StringSlice(" IP addresses/ranges (comma-separated)", defaultIPs) - g.Config.MetalLB.Pools[i] = files.MetalLBPoolDef{ + g.Config.Cluster.MetalLB.Pools[i] = files.MetalLBPoolDef{ Name: poolName, IPAddresses: poolIPs, } @@ -212,7 +220,7 @@ func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectACMEConfig(prompter *Prompter) { +func (g *InstallConfig) collectACMEConfig(prompter prompt.Prompter) { log.Println("\n=== ACME Certificate Configuration (Optional) ===") certIssuer := g.Config.Codesphere.EnsureCertIssuer() @@ -293,7 +301,7 @@ func (g *InstallConfig) collectACMEConfig(prompter *Prompter) { log.Println("Provider config and secrets should be added manually after generation.") } -func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { +func (g *InstallConfig) collectCodesphereConfig(prompter prompt.Prompter) { log.Println("\n=== Codesphere Application Configuration ===") defaultDomain := g.Config.Codesphere.Domain if defaultDomain == "" { @@ -366,7 +374,7 @@ func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { g.collectOpenfgaBackupsConfig(prompter) } -func (g *InstallConfig) collectOpenfgaBackupsConfig(prompter *Prompter) { +func (g *InstallConfig) collectOpenfgaBackupsConfig(prompter prompt.Prompter) { log.Println("\n=== OpenFGA Database Backups (Optional) ===") hasBackups := prompter.Bool("Configure OpenFGA database backups", g.Config.Codesphere.OpenfgaBackups != nil && g.Config.Codesphere.OpenfgaBackups.Enabled) if !hasBackups { @@ -411,7 +419,7 @@ func (g *InstallConfig) collectOpenfgaBackupsConfig(prompter *Prompter) { } } -func (g *InstallConfig) collectOpenBaoConfig(prompter *Prompter) { +func (g *InstallConfig) collectOpenBaoConfig(prompter prompt.Prompter) { log.Println("\n=== OpenBao Configuration (Optional) ===") hasOpenBao := prompter.Bool("Configure OpenBao integration", g.Config.Codesphere.OpenBao != nil && g.Config.Codesphere.OpenBao.URI != "") if !hasOpenBao { diff --git a/internal/installer/config_generator_collector_test.go b/internal/installer/config_generator_collector_test.go index fc5c2829d..5d9f8288e 100644 --- a/internal/installer/config_generator_collector_test.go +++ b/internal/installer/config_generator_collector_test.go @@ -4,10 +4,14 @@ package installer_test import ( + "os" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/prompt" ) var _ = Describe("ConfigGeneratorCollector", func() { @@ -16,7 +20,7 @@ var _ = Describe("ConfigGeneratorCollector", func() { ) BeforeEach(func() { - manager = installer.NewInstallConfigManager() + manager = newPlainInstallConfigManager() }) Describe("CollectInteractively", func() { @@ -31,14 +35,47 @@ var _ = Describe("ConfigGeneratorCollector", func() { Expect(config).ToNot(BeNil()) Expect(config.Datacenter.Name).ToNot(BeEmpty()) }) + + It("should preserve existing MetalLB pools as prompt defaults", func() { + err := manager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + config.Cluster.MetalLB = &files.MetalLBConfig{ + Enabled: true, + Pools: []files.MetalLBPoolDef{ + {Name: "existing-pool", IPAddresses: []string{"10.0.0.1-10.0.0.5"}}, + }, + } + + // CollectInteractively uses an interactive prompter; point stdin at an + // empty file so every prompt resolves to its default value. + stdin, err := os.CreateTemp("", "stdin-*") + Expect(err).NotTo(HaveOccurred()) + + oldStdin := os.Stdin + os.Stdin = stdin + + DeferCleanup(func() { os.Stdin = oldStdin; _ = os.Remove(stdin.Name()) }) + + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + + config = manager.GetInstallConfig() + Expect(config.Cluster.MetalLB).ToNot(BeNil()) + Expect(config.Cluster.MetalLB.Enabled).To(BeTrue()) + Expect(config.Cluster.MetalLB.Pools).To(HaveLen(1)) + Expect(config.Cluster.MetalLB.Pools[0].Name).To(Equal("existing-pool")) + Expect(config.Cluster.MetalLB.Pools[0].IPAddresses).To(Equal([]string{"10.0.0.1-10.0.0.5"})) + }) }) Describe("Prompter", func() { - var prompter *installer.Prompter + var prompter prompt.Prompter Context("Non-interactive mode", func() { BeforeEach(func() { - prompter = installer.NewPrompter(false) + prompter = prompt.NewPrompter(false) }) It("should return default string value", func() { diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 8d3eacaa4..cb3c184b3 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -44,15 +44,14 @@ type InstallConfigManager interface { GenerateSecrets() error WriteInstallConfig(configPath string, withComments bool) error WriteVault(vaultPath string, withComments bool) error - WriteUnencryptedVault(vaultPath string, withComments bool) error } type InstallConfig struct { - fileIO util.FileIO - vaultEncryptor vault.Encryptor - ageKeyResolver vault.AgeKeyResolver - Config *files.RootConfig - Vault *files.InstallVault + fileIO util.FileIO + Config *files.RootConfig + Vault *files.InstallVault + vaultType vault.Type + vaultAgeKey string } // SetFileIO overrides the file I/O implementation (useful for testing). @@ -60,39 +59,41 @@ func (g *InstallConfig) SetFileIO(fio util.FileIO) { g.fileIO = fio } -// SetVaultEncryptor overrides vault encryption (useful for testing). -func (g *InstallConfig) SetVaultEncryptor(encryptor vault.Encryptor) { - g.vaultEncryptor = encryptor -} - -// SetAgeKeyResolver overrides age key resolution (useful for testing). -func (g *InstallConfig) SetAgeKeyResolver(resolver vault.AgeKeyResolver) { - g.ageKeyResolver = resolver -} - -func (g *InstallConfig) encryptVault(src, target, recipient string) error { - if g.vaultEncryptor == nil { - return fmt.Errorf("vault encryptor is not configured") +// NewInstallConfigManager configures all vault reads and writes to go through +// the selected vault implementation. +func NewInstallConfigManager(vaultType string, ageKey string) (InstallConfigManager, error) { + t, err := vault.ParseType(vaultType) + if err != nil { + return nil, fmt.Errorf("failed to parse vault type %s: %w", vaultType, err) } - return g.vaultEncryptor.Encrypt(src, target, recipient) -} -func (g *InstallConfig) resolveAgeKey(explicitKeyFile, fallbackDir string) (recipient, keyPath string, err error) { - if g.ageKeyResolver == nil { - return "", "", fmt.Errorf("age key resolver is not configured") + if err := vault.ValidateConfiguration(t, ageKey); err != nil { + return nil, fmt.Errorf("failed to validate install config: %w", err) } - return g.ageKeyResolver.Resolve(explicitKeyFile, fallbackDir) -} -func NewInstallConfigManager() InstallConfigManager { config := files.NewRootConfig() + return &InstallConfig{ - fileIO: &util.FilesystemWriter{}, - vaultEncryptor: vault.SOPSEncryptor{}, - ageKeyResolver: vault.DefaultAgeKeyResolver{}, - Config: &config, - Vault: &files.InstallVault{}, + fileIO: &util.FilesystemWriter{}, + Config: &config, + Vault: &files.InstallVault{}, + vaultType: t, + vaultAgeKey: ageKey, + }, nil +} + +func (g *InstallConfig) vaultStore(path string, comments bool, forcedType ...vault.Type) (vault.Vault, error) { + t := g.vaultType + if len(forcedType) > 0 { + t = forcedType[0] } + + vault, err := vault.New(t, vault.Options{Path: path, AgeKey: g.vaultAgeKey, WithComments: comments, FileIO: g.fileIO}) + if err != nil { + return nil, fmt.Errorf("failed to read vault: %w", err) + } + + return vault, nil } func (g *InstallConfig) LoadInstallConfigFromFile(configPath string) error { @@ -116,26 +117,34 @@ func (g *InstallConfig) LoadInstallConfigFromFile(configPath string) error { return nil } -// LoadVaultFromFile loads the vault content from an encrypted file into the installConfig -// Returns an error if age key file has not been set as environment variable SOPS_AGE_KEY_FILE +// LoadVaultFromFile loads vault content using the manager's configured backend. +// An empty type selects the default SOPS backend. func (g *InstallConfig) LoadVaultFromFile(vaultPath string) error { - vault, err := vault.LoadVaultData(vaultPath, "") + store, err := g.vaultStore(vaultPath, false) if err != nil { - return err + return fmt.Errorf("failed to initialize vault backend: %w", err) } - g.Vault = vault + loaded, err := store.Load() + if err != nil { + return fmt.Errorf("failed to load vault: %w", err) + } + + g.Vault = loaded return nil } // LoadVaultFromUnecryptedFile loads the vault content from an unencrypted file into the installConfig func (g *InstallConfig) LoadVaultFromUnecryptedFile(vaultPath string) error { - vault, err := vault.LoadUnencryptedVaultData(vaultPath) + store, err := g.vaultStore(vaultPath, false, vault.TypePlain) if err != nil { - return err + return fmt.Errorf("failed to initialize vault backend: %w", err) } - g.Vault = vault + g.Vault, err = store.LoadOrCreate() + if err != nil { + return fmt.Errorf("failed to load vault: %w", err) + } return nil } @@ -230,6 +239,39 @@ func (g *InstallConfig) ValidateInstallConfig() []string { } } + errors = append(errors, validateOpenFga(g.Config.Codesphere.OpenFga)...) + + return errors +} + +// validateOpenFga checks the codesphere.openFga block. A data center that does not deploy +// OpenFGA has nowhere to fall back to, so it must name the instance it uses; a data center that +// exposes one must say under which host. +func validateOpenFga(config *files.OpenFgaConfig) []string { + if config == nil { + return nil + } + + errors := []string{} + if !config.DeploysOpenFga() && config.APIURL == "" { + errors = append(errors, "OpenFGA apiUrl is required when codesphere.openFga.deploy is false") + } + + if config.APIURL != "" { + if _, err := url.ParseRequestURI(config.APIURL); err != nil { + errors = append(errors, "OpenFGA apiUrl must be a valid URL") + } + } + + if config.ExposesOpenFga() { + if config.Expose.Host == "" { + errors = append(errors, "OpenFGA expose host is required when codesphere.openFga.expose.enabled is true") + } + + if !config.DeploysOpenFga() { + errors = append(errors, "OpenFGA cannot be exposed by a data center that does not deploy it") + } + } return errors } @@ -318,83 +360,19 @@ func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) return nil } -func (g *InstallConfig) WriteUnencryptedVault(vaultPath string, withComments bool) error { - vaultYAML, err := g.marshalVault(vaultPath, withComments) - if err != nil { - return err - } - - if err := g.fileIO.CreateAndWrite(vaultPath, vaultYAML, "Secrets"); err != nil { - return err - } - - return nil -} - func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { - vaultYAML, err := g.marshalVault(vaultPath, withComments) - if err != nil { - return err - } - - recipient, _, err := g.resolveAgeKey("", filepath.Dir(vaultPath)) + store, err := g.vaultStore(vaultPath, withComments) if err != nil { - return fmt.Errorf("failed to resolve age key: %w", err) + return fmt.Errorf("failed to initialize vault backend: %w", err) } - plainPath, err := g.fileIO.CreateTemp(filepath.Dir(vaultPath), "."+filepath.Base(vaultPath)+".plaintext-*") + err = store.Save(g.Vault) if err != nil { - return fmt.Errorf("failed to create temporary plaintext vault: %w", err) + return fmt.Errorf("failed to write vault: %w", err) } - defer func() { - _ = g.fileIO.Remove(plainPath) - }() - if err := g.fileIO.WriteFile(plainPath, vaultYAML, 0600); err != nil { - return fmt.Errorf("failed to write temporary plaintext vault: %w", err) - } - - encryptedPath, err := g.fileIO.CreateTemp(filepath.Dir(vaultPath), "."+filepath.Base(vaultPath)+".encrypted-*") - if err != nil { - return fmt.Errorf("failed to create temporary encrypted vault: %w", err) - } - defer func() { - _ = g.fileIO.Remove(encryptedPath) - }() - - if err := g.encryptVault(plainPath, encryptedPath, recipient); err != nil { - return err - } - - if err := g.fileIO.Chmod(encryptedPath, 0600); err != nil { - return fmt.Errorf("failed to set encrypted vault permissions: %w", err) - } - if err := g.fileIO.Rename(encryptedPath, vaultPath); err != nil { - return fmt.Errorf("failed to replace encrypted vault: %w", err) - } - return nil } -func (g *InstallConfig) marshalVault(vaultPath string, withComments bool) ([]byte, error) { - if g.Config == nil { - return nil, fmt.Errorf("no configuration provided - config is nil") - } - if g.Vault == nil { - g.Vault = &files.InstallVault{} - } - - vaultYAML, err := g.Vault.Marshal() - if err != nil { - return nil, fmt.Errorf("failed to marshal %s: %w", filepath.Base(vaultPath), err) - } - - if withComments { - vaultYAML = AddVaultComments(vaultYAML) - } - - return vaultYAML, nil -} - func AddConfigComments(yamlData []byte) []byte { header := `# Codesphere Installer Configuration # Generated by OMS CLI diff --git a/internal/installer/config_manager_ansible_test.go b/internal/installer/config_manager_ansible_test.go index 7d9d374f4..6776f7d80 100644 --- a/internal/installer/config_manager_ansible_test.go +++ b/internal/installer/config_manager_ansible_test.go @@ -22,7 +22,7 @@ var _ = Describe("ConfigManagerAnsible", func() { ) BeforeEach(func() { - manager = installer.NewInstallConfigManager() + manager = newPlainInstallConfigManager() tempDir = GinkgoT().TempDir() inventoryFilePath = filepath.Join(tempDir, "inventory.yaml") diff --git a/internal/installer/config_manager_generation_test.go b/internal/installer/config_manager_generation_test.go new file mode 100644 index 000000000..bd783034c --- /dev/null +++ b/internal/installer/config_manager_generation_test.go @@ -0,0 +1,77 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.yaml.in/yaml/v3" + + "github.com/codesphere-cloud/oms/internal/installer" +) + +var _ = Describe("Generated install config round-trip", func() { + DescribeTable("profiles generate a valid, reloadable install config", + func(profile string) { + manager := newPlainInstallConfigManager() + + err := manager.ApplyProfile(profile) + Expect(err).NotTo(HaveOccurred()) + + // A freshly generated config must already pass validation without warnings. + validationWarnings := manager.ValidateInstallConfig() + Expect(validationWarnings).To(BeEmpty(), + "profile %s produced an invalid config: %v", profile, validationWarnings) + + // Secrets generation must succeed (pure Go crypto, no external tools). + Expect(manager.GenerateSecrets()).To(Succeed()) + + // Structural invariant: MetalLB lives under cluster, not at the root. + config := manager.GetInstallConfig() + Expect(config.Cluster.MetalLB).NotTo(BeNil(), + "profile %s must configure cluster.metallb", profile) + + // Write both files to a temp dir. init install-config produces a + // plaintext vault that is SOPS-encrypted later, so use the + // unencrypted write path. + dir := GinkgoT().TempDir() + configPath := filepath.Join(dir, "config.yaml") + vaultPath := filepath.Join(dir, "prod.vault.yaml") + + Expect(manager.WriteInstallConfig(configPath, false)).To(Succeed()) + Expect(manager.WriteVault(vaultPath, false)).To(Succeed()) + + // The written config must be valid YAML with cluster.metallb (not root metallb). + raw, err := os.ReadFile(configPath) + Expect(err).NotTo(HaveOccurred()) + + var doc map[string]interface{} + Expect(yaml.Unmarshal(raw, &doc)).To(Succeed()) + Expect(doc).NotTo(HaveKey("metallb"), + "profile %s must not emit metallb at the root of config.yaml", profile) + cluster, ok := doc["cluster"].(map[string]interface{}) + Expect(ok).To(BeTrue(), "profile %s must emit a cluster mapping", profile) + Expect(cluster).To(HaveKey("metallb")) + + // Reload the config from disk through the full render+unmarshal path + // and re-validate: round-trip must be lossless and valid. + reloaded := newPlainInstallConfigManager() + Expect(reloaded.LoadInstallConfigFromFile(configPath)).To(Succeed()) + reloadedWarnings := reloaded.ValidateInstallConfig() + Expect(reloadedWarnings).To(BeEmpty(), + "reloaded config for profile %s is invalid: %v", profile, reloadedWarnings) + + // Reload the (unencrypted) vault and validate all required secrets exist. + Expect(reloaded.LoadVaultFromUnecryptedFile(vaultPath)).To(Succeed()) + Expect(reloaded.ValidateVault()).To(BeEmpty(), + "vault for profile %s is invalid: %v", profile, reloaded.ValidateVault()) + }, + Entry("dev profile", installer.PROFILE_DEV), + Entry("minimal profile", installer.PROFILE_MINIMAL), + Entry("production profile", installer.PROFILE_PROD), + ) +}) diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go index ec4f0b258..4da3a43a0 100644 --- a/internal/installer/config_manager_profile.go +++ b/internal/installer/config_manager_profile.go @@ -93,8 +93,9 @@ func (g *InstallConfig) applyCommonProperties() { if g.Config.Cluster.PublicGateway.ServiceType == "" { g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} } - if g.Config.MetalLB == nil { - g.Config.MetalLB = &files.MetalLBConfig{ + + if g.Config.Cluster.MetalLB == nil { + g.Config.Cluster.MetalLB = &files.MetalLBConfig{ Enabled: false, Pools: []files.MetalLBPoolDef{}, } @@ -103,6 +104,10 @@ func (g *InstallConfig) applyCommonProperties() { g.Config.Registry = &files.RegistryConfig{} } + certIssuer := g.Config.Codesphere.EnsureCertIssuer() + if certIssuer.Type == "" { + certIssuer.Type = files.CertIssuerTypeSelfSigned + } if g.Config.Codesphere.Domain == "" { g.Config.Codesphere.Domain = "codesphere.local" } @@ -181,24 +186,14 @@ func (g *InstallConfig) applyCommonProperties() { } else if g.Config.ManagedServiceBackends.Postgres == nil { g.Config.ManagedServiceBackends.Postgres = &files.PgManagedServiceConfig{} } - if g.Config.Codesphere.ManagedServices == nil { - g.Config.Codesphere.ManagedServices = []files.ManagedServiceConfig{ - {Name: "postgres", Version: "v1"}, - {Name: "babelfish", Version: "v1"}, - {Name: "s3", Version: "v1"}, - {Name: "virtual-k8s", Version: "v1"}, - {Name: "ferretdb", Version: "v0"}, - } - } if g.Config.Secrets.BaseDir == "" { g.Config.Secrets.BaseDir = "/root/secrets" } } -func (g *InstallConfig) applyProfileDev() error { - if g.Config.Datacenter.Name == "" { - g.Config.Datacenter.Name = "dev" - } +// ensureMonitoringDefaults populates the monitoring stack with the given enabled +// states, preserving any explicitly configured values. +func (g *InstallConfig) ensureMonitoringDefaults(clusterName string, loki, grafana, alloy bool) { if g.Config.Cluster.Monitoring == nil { g.Config.Cluster.Monitoring = &files.MonitoringConfig{} } @@ -208,18 +203,38 @@ func (g *InstallConfig) applyProfileDev() error { if g.Config.Cluster.Monitoring.Prometheus.RemoteWrite == nil { g.Config.Cluster.Monitoring.Prometheus.RemoteWrite = &files.RemoteWriteConfig{ Enabled: false, - ClusterName: "dev", + ClusterName: clusterName, } } if g.Config.Cluster.Monitoring.Loki == nil { - g.Config.Cluster.Monitoring.Loki = &files.LokiConfig{Enabled: false} + g.Config.Cluster.Monitoring.Loki = &files.LokiConfig{Enabled: loki} } if g.Config.Cluster.Monitoring.Grafana == nil { - g.Config.Cluster.Monitoring.Grafana = &files.GrafanaConfig{Enabled: false} + g.Config.Cluster.Monitoring.Grafana = &files.GrafanaConfig{Enabled: grafana} } if g.Config.Cluster.Monitoring.GrafanaAlloy == nil { - g.Config.Cluster.Monitoring.GrafanaAlloy = &files.GrafanaAlloyConfig{Enabled: false} + g.Config.Cluster.Monitoring.GrafanaAlloy = &files.GrafanaAlloyConfig{Enabled: alloy} + } +} + +// ensureWorkspacePlans sets the default workspace plan for plan ID 1. +func (g *InstallConfig) ensureWorkspacePlans(name string, maxReplicas int) { + g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ + 1: { + Name: name, + HostingPlanID: 1, + MaxReplicas: maxReplicas, + OnDemand: true, + }, + } +} + +func (g *InstallConfig) applyProfileDev() error { + if g.Config.Datacenter.Name == "" { + g.Config.Datacenter.Name = "dev" } + + g.ensureMonitoringDefaults("dev", false, false, false) if err := ApplyResourceProfile(g.Config, ResourceProfileNoRequests); err != nil { return fmt.Errorf("applying resource profile: %w", err) } @@ -230,37 +245,9 @@ func (g *InstallConfig) applyProfileMinimal() error { if g.Config.Datacenter.Name == "" { g.Config.Datacenter.Name = "dev" } - if g.Config.Cluster.Monitoring == nil { - g.Config.Cluster.Monitoring = &files.MonitoringConfig{} - } - if g.Config.Cluster.Monitoring.Prometheus == nil { - g.Config.Cluster.Monitoring.Prometheus = &files.PrometheusConfig{} - } - if g.Config.Cluster.Monitoring.Prometheus.RemoteWrite == nil { - g.Config.Cluster.Monitoring.Prometheus.RemoteWrite = &files.RemoteWriteConfig{ - Enabled: false, - ClusterName: "dev", - } - } - if g.Config.Cluster.Monitoring.Loki == nil { - g.Config.Cluster.Monitoring.Loki = &files.LokiConfig{Enabled: true} - } - if g.Config.Cluster.Monitoring.Grafana == nil { - g.Config.Cluster.Monitoring.Grafana = &files.GrafanaConfig{Enabled: true} - } - if g.Config.Cluster.Monitoring.GrafanaAlloy == nil { - g.Config.Cluster.Monitoring.GrafanaAlloy = &files.GrafanaAlloyConfig{Enabled: true} - } - if g.Config.Codesphere.Plans.WorkspacePlans == nil { - g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ - 1: { - Name: "Standard Developer", - HostingPlanID: 1, - MaxReplicas: 1, - OnDemand: true, - }, - } - } + + g.ensureMonitoringDefaults("dev", true, true, true) + g.ensureWorkspacePlans("Standard Developer", 1) if g.Config.Cluster.BarmanCloudPlugin == nil { g.Config.Cluster.BarmanCloudPlugin = &files.BarmanCloudPluginConfig{ Enabled: true, @@ -287,16 +274,8 @@ func (g *InstallConfig) applyProfileProd() error { if g.Config.Datacenter.Name == "" { g.Config.Datacenter.Name = "production" } - if g.Config.Codesphere.Plans.WorkspacePlans == nil { - g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ - 1: { - Name: "Standard Developer", - HostingPlanID: 1, - MaxReplicas: 3, - OnDemand: true, - }, - } - } + + g.ensureWorkspacePlans("Standard Developer", 3) g.Config.Cluster.Monitoring = &files.MonitoringConfig{ Prometheus: &files.PrometheusConfig{ RemoteWrite: &files.RemoteWriteConfig{ diff --git a/internal/installer/config_manager_profile_test.go b/internal/installer/config_manager_profile_test.go index 23fd2eb2b..fc7ccc5e7 100644 --- a/internal/installer/config_manager_profile_test.go +++ b/internal/installer/config_manager_profile_test.go @@ -16,7 +16,7 @@ var _ = Describe("ConfigManagerProfile", func() { var manager installer.InstallConfigManager BeforeEach(func() { - manager = installer.NewInstallConfigManager() + manager = newPlainInstallConfigManager() }) Describe("ApplyProfile", func() { @@ -103,8 +103,8 @@ var _ = Describe("ConfigManagerProfile", func() { Expect(config.Cluster.PublicGateway.ServiceType).To(Equal("LoadBalancer")) // MetalLB - Expect(config.MetalLB).ToNot(BeNil()) - Expect(config.MetalLB.Enabled).To(BeFalse()) + Expect(config.Cluster.MetalLB).ToNot(BeNil()) + Expect(config.Cluster.MetalLB.Enabled).To(BeFalse()) // Ceph OSDs Expect(config.Ceph.OSDs).To(HaveLen(1)) @@ -149,9 +149,8 @@ var _ = Describe("ConfigManagerProfile", func() { Expect(config.ManagedServiceBackends).ToNot(BeNil()) Expect(config.ManagedServiceBackends.Postgres).ToNot(BeNil()) - // Managed service config - Expect(config.Codesphere.ManagedServices).ToNot(BeNil()) - Expect(len(config.Codesphere.ManagedServices)).To(Equal(5)) + // Managed service config is opt-in, with no defaults + Expect(config.Codesphere.ManagedServices).To(BeNil()) // Secrets Expect(config.Secrets.BaseDir).To(Equal("/root/secrets")) @@ -161,9 +160,9 @@ var _ = Describe("ConfigManagerProfile", func() { Context("profile-specific differences", func() { It("should have the expected datacenter names", func() { - devManager := installer.NewInstallConfigManager() - prodManager := installer.NewInstallConfigManager() - minimalManager := installer.NewInstallConfigManager() + devManager := newPlainInstallConfigManager() + prodManager := newPlainInstallConfigManager() + minimalManager := newPlainInstallConfigManager() err := devManager.ApplyProfile(installer.PROFILE_DEV) Expect(err).ToNot(HaveOccurred()) @@ -178,9 +177,9 @@ var _ = Describe("ConfigManagerProfile", func() { }) It("should have different resource profiles", func() { - devManager := installer.NewInstallConfigManager() - prodManager := installer.NewInstallConfigManager() - minimalManager := installer.NewInstallConfigManager() + devManager := newPlainInstallConfigManager() + prodManager := newPlainInstallConfigManager() + minimalManager := newPlainInstallConfigManager() err := devManager.ApplyProfile(installer.PROFILE_DEV) Expect(err).ToNot(HaveOccurred()) @@ -207,6 +206,33 @@ var _ = Describe("ConfigManagerProfile", func() { Expect(prodManager.GetInstallConfig().Cluster.Monitoring.GrafanaAlloy.Enabled).To(BeTrue()) Expect(prodManager.GetInstallConfig().Codesphere.Override).To(BeNil()) }) + + It("should have the expected default workspace plans", func() { + devManager := newPlainInstallConfigManager() + prodManager := newPlainInstallConfigManager() + minimalManager := newPlainInstallConfigManager() + + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + err = minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) + + plan := func(mgr installer.InstallConfigManager) files.WorkspacePlan { + return mgr.GetInstallConfig().Codesphere.Plans.WorkspacePlans[1] + } + + // Dev inherits the common default. + Expect(plan(devManager).Name).To(Equal("Standard")) + Expect(plan(devManager).MaxReplicas).To(Equal(3)) + + // Minimal and production override the name and replicas. + Expect(plan(minimalManager).Name).To(Equal("Standard Developer")) + Expect(plan(minimalManager).MaxReplicas).To(Equal(1)) + Expect(plan(prodManager).Name).To(Equal("Standard Developer")) + Expect(plan(prodManager).MaxReplicas).To(Equal(3)) + }) }) }) diff --git a/internal/installer/config_manager_secrets_test.go b/internal/installer/config_manager_secrets_test.go index 993fb4f22..20d8f276d 100644 --- a/internal/installer/config_manager_secrets_test.go +++ b/internal/installer/config_manager_secrets_test.go @@ -17,10 +17,8 @@ var _ = Describe("GenerateSecrets", func() { var mgr *installer.InstallConfig BeforeEach(func() { - mgr = &installer.InstallConfig{ - Config: &files.RootConfig{}, - Vault: &files.InstallVault{}, - } + mgr = newPlainInstallConfigManager().(*installer.InstallConfig) + mgr.Config = &files.RootConfig{} }) Context("with basic configuration (no postgres)", func() { @@ -187,10 +185,8 @@ var _ = Describe("GenerateSecrets", func() { Context("uniqueness", func() { It("generates different secrets for different instances", func() { - mgr2 := &installer.InstallConfig{ - Config: &files.RootConfig{}, - Vault: &files.InstallVault{}, - } + mgr2 := newPlainInstallConfigManager().(*installer.InstallConfig) + mgr2.Config = &files.RootConfig{} Expect(mgr.GenerateSecrets()).To(Succeed()) Expect(mgr2.GenerateSecrets()).To(Succeed()) diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go index 3b6830b80..42733f855 100644 --- a/internal/installer/config_manager_test.go +++ b/internal/installer/config_manager_test.go @@ -5,7 +5,6 @@ package installer_test import ( "bytes" - "errors" "os" "path/filepath" @@ -15,7 +14,6 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/secrets" - "github.com/codesphere-cloud/oms/internal/installer/vault" ) type MockFileIO struct { @@ -137,14 +135,13 @@ var _ = Describe("ConfigManager", func() { ) BeforeEach(func() { - configManager = &installer.InstallConfig{ - Config: &files.RootConfig{}, - } + manager := newPlainInstallConfigManager() + configManager = manager.(*installer.InstallConfig) }) Describe("NewInstallConfigManager", func() { It("should create a new config manager", func() { - manager := installer.NewInstallConfigManager() + manager := newPlainInstallConfigManager() Expect(manager).ToNot(BeNil()) }) }) @@ -342,6 +339,55 @@ var _ = Describe("ConfigManager", func() { }) }) + Context("openFga validation", func() { + It("should accept an absent openFga block", func() { + configManager.Config.Codesphere.OpenFga = nil + errors := configManager.ValidateInstallConfig() + Expect(errors).NotTo(ContainElement(ContainSubstring("OpenFGA"))) + }) + + It("should require an apiUrl when the data center does not deploy OpenFGA", func() { + deploy := false + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{Deploy: &deploy} + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("OpenFGA apiUrl is required"))) + }) + + It("should validate the apiUrl format", func() { + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{APIURL: "not-a-valid-url"} + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("OpenFGA apiUrl must be a valid URL"))) + }) + + It("should require a host when exposing OpenFGA", func() { + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true}, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("OpenFGA expose host is required"))) + }) + + It("should reject exposing an OpenFGA the data center does not deploy", func() { + deploy := false + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{ + Deploy: &deploy, + APIURL: "https://openfga.1.cs.example.com", + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.2.cs.example.com"}, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("cannot be exposed by a data center that does not deploy it"))) + }) + + It("should accept a data center that deploys and exposes OpenFGA", func() { + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{ + APIURL: "https://openfga.1.cs.example.com", + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).NotTo(ContainElement(ContainSubstring("OpenFGA"))) + }) + }) + Context("ceph validation", func() { It("should require at least one Ceph host", func() { configManager.Config.Ceph.Hosts = []files.CephHost{} @@ -546,39 +592,11 @@ var _ = Describe("ConfigManager", func() { }) Describe("WriteVault", func() { - It("should return error if config is nil", func() { + It("writes independently of the install config", func() { configManager.Config = nil - err := configManager.WriteVault("/tmp/vault.yaml", false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no configuration provided")) - }) - }) - - Describe("WriteVault", func() { - It("should preserve the existing vault when encryption fails", func() { - vaultPath := "prod.vault.yaml" - original := []byte("existing encrypted content") - mockIO := NewMockFileIO() - mockIO.files[vaultPath] = original - manager := &installer.InstallConfig{ - Config: &files.RootConfig{}, - Vault: &files.InstallVault{}, - } - ageKeyResolver := vault.NewMockAgeKeyResolver(GinkgoT()) - ageKeyResolver.EXPECT().Resolve("", ".").Return("recipient", "", nil) - encryptor := vault.NewMockEncryptor(GinkgoT()) - encryptor.EXPECT().Encrypt( - ".prod.vault.yaml.plaintext-*mock", - ".prod.vault.yaml.encrypted-*mock", - "recipient", - ).Return(errors.New("encryption failed")) - manager.SetFileIO(mockIO) - manager.SetAgeKeyResolver(ageKeyResolver) - manager.SetVaultEncryptor(encryptor) - - err := manager.WriteVault(vaultPath, false) - Expect(err).To(HaveOccurred()) - Expect(mockIO.GetFileContent(vaultPath)).To(Equal(original)) + vaultPath := filepath.Join(GinkgoT().TempDir(), "vault.yaml") + Expect(configManager.WriteVault(vaultPath, false)).To(Succeed()) + Expect(vaultPath).To(BeAnExistingFile()) }) }) @@ -609,9 +627,8 @@ var _ = Describe("ConfigManager", func() { }) Context("vault deduplication on re-write", func() { - It("should not produce duplicate vault entries when WriteUnencryptedVault is called after loading existing vault", func() { - mockIO := NewMockFileIO() - configManager.SetFileIO(mockIO) + It("should not produce duplicate vault entries when WriteVault is called after loading existing vault", func() { + vaultPath := filepath.Join(GinkgoT().TempDir(), "vault.yaml") err := configManager.ApplyProfile("prod") Expect(err).ToNot(HaveOccurred()) @@ -619,11 +636,12 @@ var _ = Describe("ConfigManager", func() { err = configManager.GenerateSecrets() Expect(err).ToNot(HaveOccurred()) - // First write via WriteUnencryptedVault - err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) + // First write via WriteVault + err = configManager.WriteVault(vaultPath, false) Expect(err).ToNot(HaveOccurred()) - firstVaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + firstVaultBytes, err := os.ReadFile(vaultPath) + Expect(err).NotTo(HaveOccurred()) Expect(firstVaultBytes).ToNot(BeEmpty()) // Load the written vault back @@ -633,10 +651,11 @@ var _ = Describe("ConfigManager", func() { configManager.Vault = vault // Re-write vault (simulating a second run) - err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) + err = configManager.WriteVault(vaultPath, false) Expect(err).ToNot(HaveOccurred()) - secondVaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + secondVaultBytes, err := os.ReadFile(vaultPath) + Expect(err).NotTo(HaveOccurred()) Expect(secondVaultBytes).To(Equal(firstVaultBytes), "serialized vault should be identical after load and re-write") }) @@ -647,6 +666,9 @@ var _ = Describe("ConfigManager", func() { mockIO := NewMockFileIO() configManager.SetFileIO(mockIO) + vaultPath := filepath.Join(GinkgoT().TempDir(), "vault.yaml") + vaultPath2 := filepath.Join(GinkgoT().TempDir(), "vault2.yaml") + // --- First run: generate everything from scratch --- err := configManager.ApplyProfile("prod") Expect(err).ToNot(HaveOccurred()) @@ -672,11 +694,11 @@ var _ = Describe("ConfigManager", func() { // Write config and vault err = configManager.WriteInstallConfig("/tmp/config.yaml", false) Expect(err).ToNot(HaveOccurred()) - err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) + err = configManager.WriteVault(vaultPath, false) Expect(err).ToNot(HaveOccurred()) // --- Second run: simulate loading existing files --- - configManager2 := &installer.InstallConfig{} + configManager2 := newPlainInstallConfigManager().(*installer.InstallConfig) configManager2.SetFileIO(mockIO) // Reload config from written YAML @@ -691,7 +713,8 @@ var _ = Describe("ConfigManager", func() { "cert should be in config.yaml") // Reload vault from written YAML - vaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + vaultBytes, err := mockIO.ReadFile(vaultPath) + Expect(err).NotTo(HaveOccurred()) Expect(vaultBytes).ToNot(BeNil()) vault2 := &files.InstallVault{} err = vault2.Unmarshal(vaultBytes) @@ -712,12 +735,13 @@ var _ = Describe("ConfigManager", func() { Expect(err).ToNot(HaveOccurred(), "cert/key should match after load from vault") // Write vault again - err = configManager2.WriteUnencryptedVault("/tmp/vault2.yaml", false) + err = configManager2.WriteVault(vaultPath2, false) Expect(err).ToNot(HaveOccurred()) // Verify no duplicates in re-written vault vault3 := &files.InstallVault{} - vaultBytes2 := mockIO.GetFileContent("/tmp/vault2.yaml") + vaultBytes2, err := mockIO.ReadFile(vaultPath2) + Expect(err).NotTo(HaveOccurred()) err = vault3.Unmarshal(vaultBytes2) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/installer/config_manager_test_helpers_test.go b/internal/installer/config_manager_test_helpers_test.go new file mode 100644 index 000000000..9c812f4e4 --- /dev/null +++ b/internal/installer/config_manager_test_helpers_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "github.com/codesphere-cloud/oms/internal/installer" + . "github.com/onsi/gomega" +) + +func newPlainInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("plain", "") + Expect(err).NotTo(HaveOccurred()) + + return manager +} diff --git a/internal/installer/config_template_test.go b/internal/installer/config_template_test.go index c314a8a60..bea47366a 100644 --- a/internal/installer/config_template_test.go +++ b/internal/installer/config_template_test.go @@ -16,6 +16,7 @@ import ( "github.com/codesphere-cloud/oms/internal/configtemplating" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" ) func sopsAndAgeAvailable() bool { @@ -106,7 +107,7 @@ codesphere: Expect(exec.Command("age-keygen", "-o", ageKeyPath).Run()).To(Succeed()) recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() Expect(err).NotTo(HaveOccurred()) - Expect(vault.EncryptFileWithSOPS(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) + Expect(sops.EncryptFile(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) renderedPath, cleanup, err := configtemplating.RenderConfigFileToTemp( configPath, @@ -128,7 +129,9 @@ codesphere: Expect(err).NotTo(HaveOccurred()) Expect(os.WriteFile(vaultPath, vaultYaml, 0600)).To(Succeed()) - _, err = vault.LoadVaultData(vaultPath, "") + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultPath, AgeKey: filepath.Join(tempDir, "unused-age-key")}) + Expect(err).NotTo(HaveOccurred()) + _, err = backend.Load() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("is not SOPS-encrypted")) diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index cce8757e3..b0f3050f4 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -106,7 +106,6 @@ type RootConfig struct { Ceph CephConfig `yaml:"ceph"` Kubernetes KubernetesConfig `yaml:"kubernetes"` Cluster ClusterConfig `yaml:"cluster"` - MetalLB *MetalLBConfig `yaml:"metallb,omitempty"` Codesphere CodesphereConfig `yaml:"codesphere"` PcApps ChartValues `yaml:"pcApps,omitempty"` ManagedServiceBackends *ManagedServiceBackendsConfig `yaml:"managedServiceBackends,omitempty"` @@ -228,6 +227,7 @@ type ClusterConfig struct { Monitoring *MonitoringConfig `yaml:"monitoring,omitempty"` Gateway GatewayConfig `yaml:"gateway"` PublicGateway GatewayConfig `yaml:"publicGateway"` + MetalLB *MetalLBConfig `yaml:"metallb,omitempty"` RookExternalCluster *RookExternalClusterConfig `yaml:"rookExternalCluster,omitempty"` PgOperator *PgOperatorConfig `yaml:"pgOperator,omitempty"` BarmanCloudPlugin *BarmanCloudPluginConfig `yaml:"BarmanCloudPluginConfig,omitempty"` @@ -361,6 +361,7 @@ type CodesphereConfig struct { ManagedServices []ManagedServiceConfig `yaml:"managedServices,omitempty"` OpenBao *OpenBaoConfig `yaml:"openBao,omitempty"` OpenfgaBackups *OpenfgaBackupsConfig `yaml:"openfgaBackups,omitempty"` + OpenFga *OpenFgaConfig `yaml:"openFga,omitempty"` Migration *MigrationConfig `yaml:"migration,omitempty"` TelemetryExport *TelemetryExport `yaml:"telemetryExport,omitempty"` Override ChartOverride `yaml:"override,omitempty"` @@ -398,6 +399,39 @@ type OpenfgaBackupsConfig struct { RetentionPolicy string `yaml:"retentionPolicy,omitempty"` } +// OpenFgaConfig configures the authorization store. One OpenFGA instance serves a whole +// installation, so in a multi-data-center setup exactly one data center deploys and exposes +// it (Deploy + Expose) and every other one only points at it (APIURL). +type OpenFgaConfig struct { + // Deploy controls whether pc-applications deploys OpenFGA in this data center. + // Defaults to true when unset, matching the pc-applications chart. + Deploy *bool `yaml:"deploy,omitempty"` + // APIURL is the URL the Codesphere services reach OpenFGA at. Defaults to the + // in-cluster service of a locally deployed OpenFGA; required when Deploy is false. + APIURL string `yaml:"apiUrl,omitempty"` + // Expose publishes the deployed OpenFGA through the Codesphere gateway so the other + // data centers can reach it. + Expose *OpenFgaExposeConfig `yaml:"expose,omitempty"` +} + +// OpenFgaExposeConfig publishes a locally deployed OpenFGA through the Codesphere gateway. +type OpenFgaExposeConfig struct { + Enabled bool `yaml:"enabled"` + // Host OpenFGA is served under. Must resolve to this data center's public IP and + // is what the other data centers put in their APIURL. + Host string `yaml:"host,omitempty"` +} + +// DeploysOpenFga reports whether pc-applications should deploy OpenFGA in this data center. +func (c *OpenFgaConfig) DeploysOpenFga() bool { + return c == nil || c.Deploy == nil || *c.Deploy +} + +// ExposesOpenFga reports whether the deployed OpenFGA is published through the gateway. +func (c *OpenFgaConfig) ExposesOpenFga() bool { + return c != nil && c.Expose != nil && c.Expose.Enabled +} + type OAuthProvidersConfig struct { Oidc *OidcOAuthProvider `yaml:"oidc,omitempty"` } @@ -717,7 +751,6 @@ func (c *RootConfig) Unmarshal(data []byte) error { func NewRootConfig() RootConfig { return RootConfig{ Registry: &RegistryConfig{}, - MetalLB: &MetalLBConfig{}, PcApps: ChartValues{}, ManagedServiceBackends: &ManagedServiceBackendsConfig{}, } diff --git a/internal/installer/helm_client.go b/internal/installer/helm_client.go index be018a948..034c9c8c0 100644 --- a/internal/installer/helm_client.go +++ b/internal/installer/helm_client.go @@ -28,6 +28,12 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) +// maxReleaseHistory caps how many revisions Helm keeps per release. The Helm +// SDK defaults to 0 (unlimited), unlike the CLI which defaults to 10. Every +// upgrade stores the full chart in a Secret, so an uncapped release grows +// without bound and eventually makes listing releases too large to transfer. +const maxReleaseHistory = 10 + // ReleaseInfo holds the details of an existing Helm release that the rest of // the application cares about — completely decoupled from the Helm SDK types. type ReleaseInfo struct { @@ -333,6 +339,7 @@ func (h *helmClient) UpgradeChart(ctx context.Context, cfg ChartConfig, opts Upg upgradeClient.Version = cfg.Version upgradeClient.RepoURL = cfg.RepoURL upgradeClient.Timeout = 5 * time.Minute + upgradeClient.MaxHistory = maxReleaseHistory upgradeClient.ForceConflicts = opts.ForceConflicts upgradeClient.TakeOwnership = opts.TakeOwnership diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index dba0f4be2..6f36a2239 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -833,63 +833,6 @@ func (_c *MockInstallConfigManager_WriteInstallConfig_Call) RunAndReturn(run fun return _c } -// WriteUnencryptedVault provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) WriteUnencryptedVault(vaultPath string, withComments bool) error { - ret := _mock.Called(vaultPath, withComments) - - if len(ret) == 0 { - panic("no return value specified for WriteUnencryptedVault") - } - - var r0 error - if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { - r0 = returnFunc(vaultPath, withComments) - } else { - r0 = ret.Error(0) - } - return r0 -} - -// MockInstallConfigManager_WriteUnencryptedVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteUnencryptedVault' -type MockInstallConfigManager_WriteUnencryptedVault_Call struct { - *mock.Call -} - -// WriteUnencryptedVault is a helper method to define mock.On call -// - vaultPath string -// - withComments bool -func (_e *MockInstallConfigManager_Expecter) WriteUnencryptedVault(vaultPath any, withComments any) *MockInstallConfigManager_WriteUnencryptedVault_Call { - return &MockInstallConfigManager_WriteUnencryptedVault_Call{Call: _e.mock.On("WriteUnencryptedVault", vaultPath, withComments)} -} - -func (_c *MockInstallConfigManager_WriteUnencryptedVault_Call) Run(run func(vaultPath string, withComments bool)) *MockInstallConfigManager_WriteUnencryptedVault_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 string - if args[0] != nil { - arg0 = args[0].(string) - } - var arg1 bool - if args[1] != nil { - arg1 = args[1].(bool) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockInstallConfigManager_WriteUnencryptedVault_Call) Return(err error) *MockInstallConfigManager_WriteUnencryptedVault_Call { - _c.Call.Return(err) - return _c -} - -func (_c *MockInstallConfigManager_WriteUnencryptedVault_Call) RunAndReturn(run func(vaultPath string, withComments bool) error) *MockInstallConfigManager_WriteUnencryptedVault_Call { - _c.Call.Return(run) - return _c -} - // WriteVault provides a mock function for the type MockInstallConfigManager func (_mock *MockInstallConfigManager) WriteVault(vaultPath string, withComments bool) error { ret := _mock.Called(vaultPath, withComments) diff --git a/internal/installer/openbao.go b/internal/installer/openbao.go index 885750892..ae9377ab3 100644 --- a/internal/installer/openbao.go +++ b/internal/installer/openbao.go @@ -18,7 +18,7 @@ import ( "time" "github.com/codesphere-cloud/oms/internal/bootstrap" - "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" k8s "github.com/codesphere-cloud/oms/internal/util" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -280,7 +280,7 @@ func (o *OpenBaoInstaller) PreFlightDRCheck() error { o.Logger.Logf("Found existing DR backup at %s", o.Config.DRBackupPath) - decrypted, err := vault.DecryptFileWithSOPS(o.Config.DRBackupPath, o.Config.AgeKeyPath) + decrypted, err := sops.DecryptFile(o.Config.DRBackupPath, o.Config.AgeKeyPath) if err != nil { return err } @@ -687,7 +687,7 @@ func (o *OpenBaoInstaller) ExtractAndEncrypt() error { return fmt.Errorf("closing temp backup file: %w", err) } - if err := vault.EncryptFileWithSOPS(tmpPath, o.Config.DRBackupPath, o.Config.AgeRecipient); err != nil { + if err := sops.EncryptFile(tmpPath, o.Config.DRBackupPath, o.Config.AgeRecipient); err != nil { return fmt.Errorf("encrypting DR backup: %w", err) } diff --git a/internal/installer/openfga_pc_apps.go b/internal/installer/openfga_pc_apps.go new file mode 100644 index 000000000..00d29a863 --- /dev/null +++ b/internal/installer/openfga_pc_apps.go @@ -0,0 +1,116 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "log" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +// openFgaPresharedKeysSecret is the Secret the openfga chart reads the preshared key from. +// Its content is synced out of the installation vault by the chart's own ExternalSecret, so +// oms only has to name it. +const openFgaPresharedKeysSecret = "openfga-preshared-keys" + +// OpenFgaPcAppsValues derives the pc-applications values for OpenFGA. They are the *base* of +// the pc-apps values: an explicit `pcApps` block in config.yaml and any --pc-apps-values file +// still override them. Returns nil when there is nothing to say, leaving the chart defaults +// untouched. +func OpenFgaPcAppsValues(config *files.RootConfig, vault *files.InstallVault) files.ChartValues { + application := files.ChartValues{} + chartValues := files.ChartValues{} + + if fga := config.Codesphere.OpenFga; fga != nil { + application["enabled"] = fga.DeploysOpenFga() + + if fga.Expose != nil { + chartValues["gateway"] = gatewayValues(config, fga.Expose) + } + } + + if authn := authnValues(vault); authn != nil { + chartValues["openfga"] = files.ChartValues{"authn": authn} + } + + if len(chartValues) > 0 { + application["valuesObject"] = chartValues + } + + if len(application) == 0 { + return nil + } + + return files.ChartValues{ + "applications": files.ChartValues{ + "openfga": application, + }, + } +} + +// authnValues makes OpenFGA require the preshared key exactly when the installation has one, +// so it stays in step with the Codesphere services: they read the same vault entry and treat +// it as optional too. Returns nil for an installation without the key, which runs an +// unauthenticated OpenFGA. Services that started before the key existed only send it once +// their pods roll. +func authnValues(vault *files.InstallVault) files.ChartValues { + if !hasOpenFgaPresharedKey(vault) { + log.Printf( + "OpenFGA: %s is not in the vault, deploying OpenFGA without authentication."+ + " Add the key with `oms update install-config` — a future version will require it.\n", + files.SecretOpenFgaPresharedKey, + ) + + return nil + } + + return files.ChartValues{ + "method": "preshared", + "preshared": files.ChartValues{ + "keysSecret": openFgaPresharedKeysSecret, + }, + } +} + +// gatewayValues publishes a locally deployed OpenFGA through the Codesphere gateway, so the +// Codesphere services of the other data centers can reach it. +func gatewayValues(config *files.RootConfig, expose *files.OpenFgaExposeConfig) files.ChartValues { + gateway := files.ChartValues{"enabled": expose.Enabled} + if expose.Host != "" { + gateway["host"] = expose.Host + } + + // The cert-manager ClusterIssuer the cluster step creates is named after the + // configured issuer type, so the gateway certificate follows the same issuer as + // the Codesphere frontend gateway. + gateway["tls"] = files.ChartValues{ + "certificate": files.ChartValues{ + "issuerRef": files.ChartValues{"name": certIssuerName(config)}, + }, + } + + return gateway +} + +// hasOpenFgaPresharedKey reports whether the vault holds a usable preshared key. A vault +// written by an older oms has no entry at all; `oms update install-config` adds one. +func hasOpenFgaPresharedKey(vault *files.InstallVault) bool { + if vault == nil { + return false + } + + secret := vault.GetSecret(files.SecretOpenFgaPresharedKey) + + return secret != nil && secret.Fields != nil && secret.Fields.Password != "" +} + +// certIssuerName returns the name of the ClusterIssuer for this installation, matching the +// naming the cluster step uses (the issuer type is the issuer name). +func certIssuerName(config *files.RootConfig) string { + if issuer := config.Codesphere.CertIssuer; issuer != nil && issuer.Type != "" { + return string(issuer.Type) + } + + return string(files.CertIssuerTypeSelfSigned) +} diff --git a/internal/installer/openfga_pc_apps_test.go b/internal/installer/openfga_pc_apps_test.go new file mode 100644 index 000000000..bc502a269 --- /dev/null +++ b/internal/installer/openfga_pc_apps_test.go @@ -0,0 +1,154 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +var _ = Describe("OpenFgaPcAppsValues", func() { + configWith := func(fga *files.OpenFgaConfig, issuer files.CertIssuerType) *files.RootConfig { + config := &files.RootConfig{} + config.Codesphere.OpenFga = fga + config.Codesphere.CertIssuer = &files.CertIssuerConfig{Type: issuer} + + return config + } + + // A vault of an installation that has no preshared key, i.e. everything written + // before oms started generating one. + emptyVault := func() *files.InstallVault { + return &files.InstallVault{} + } + + vaultWithPresharedKey := func() *files.InstallVault { + return &files.InstallVault{Secrets: []files.SecretEntry{{ + Name: files.SecretOpenFgaPresharedKey, + Fields: &files.SecretFields{Password: "0123456789abcdef"}, + }}} + } + + // The application entry of the rendered values, or nil if there is none. + openfgaValues := func(values files.ChartValues) files.ChartValues { + apps, ok := values["applications"].(files.ChartValues) + Expect(ok).To(BeTrue(), "expected an applications map") + + return apps["openfga"].(files.ChartValues) + } + + // The authn block the openfga chart is configured with, or nil if there is none. + authnValues := func(values files.ChartValues) files.ChartValues { + chartValues, ok := openfgaValues(values)["valuesObject"].(files.ChartValues) + if !ok { + return nil + } + + openfga, ok := chartValues["openfga"].(files.ChartValues) + if !ok { + return nil + } + + return openfga["authn"].(files.ChartValues) + } + + It("leaves the chart defaults alone when the config says nothing", func() { + Expect(installer.OpenFgaPcAppsValues(configWith(nil, ""), emptyVault())).To(BeNil()) + }) + + It("disables the application for a data center that uses a remote OpenFGA", func() { + deploy := false + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Deploy: &deploy, + APIURL: "https://openfga.1.cs.example.com", + }, files.CertIssuerTypeACME), emptyVault()) + + fga := openfgaValues(values) + Expect(fga["enabled"]).To(BeFalse()) + // Nothing to expose, so the gateway is not configured at all. + Expect(fga).NotTo(HaveKey("valuesObject")) + }) + + It("defaults to deploying when only the exposure is configured", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, files.CertIssuerTypeACME), emptyVault()) + + fga := openfgaValues(values) + Expect(fga["enabled"]).To(BeTrue()) + + gateway := fga["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + Expect(gateway["enabled"]).To(BeTrue()) + Expect(gateway["host"]).To(Equal("openfga.1.cs.example.com")) + }) + + It("issues the gateway certificate with the installation's cert issuer", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, files.CertIssuerTypeACME), emptyVault()) + + gateway := openfgaValues(values)["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + issuerRef := gateway["tls"].(files.ChartValues)["certificate"].(files.ChartValues)["issuerRef"].(files.ChartValues) + Expect(issuerRef["name"]).To(Equal("acme")) + }) + + It("falls back to the self-signed issuer when no cert issuer is configured", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, ""), emptyVault()) + + gateway := openfgaValues(values)["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + issuerRef := gateway["tls"].(files.ChartValues)["certificate"].(files.ChartValues)["issuerRef"].(files.ChartValues) + Expect(issuerRef["name"]).To(Equal("self-signed")) + }) + + It("falls back to the self-signed issuer when the cert issuer block is absent", func() { + config := configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, "") + config.Codesphere.CertIssuer = nil + + values := installer.OpenFgaPcAppsValues(config, emptyVault()) + + gateway := openfgaValues(values)["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + issuerRef := gateway["tls"].(files.ChartValues)["certificate"].(files.ChartValues)["issuerRef"].(files.ChartValues) + Expect(issuerRef["name"]).To(Equal("self-signed")) + }) + + It("leaves OpenFGA unauthenticated while the vault has no preshared key", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{}, ""), emptyVault()) + + Expect(authnValues(values)).To(BeNil()) + }) + + It("requires the preshared key once the vault holds one", func() { + values := installer.OpenFgaPcAppsValues(configWith(nil, ""), vaultWithPresharedKey()) + + authn := authnValues(values) + Expect(authn["method"]).To(Equal("preshared")) + Expect(authn["preshared"].(files.ChartValues)["keysSecret"]).To(Equal("openfga-preshared-keys")) + }) + + It("keeps the gateway configuration when authentication is derived as well", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, files.CertIssuerTypeACME), vaultWithPresharedKey()) + + Expect(authnValues(values)["method"]).To(Equal("preshared")) + gateway := openfgaValues(values)["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + Expect(gateway["host"]).To(Equal("openfga.1.cs.example.com")) + }) + + It("ignores an entry that is present but empty", func() { + vault := &files.InstallVault{Secrets: []files.SecretEntry{{ + Name: files.SecretOpenFgaPresharedKey, + Fields: &files.SecretFields{Password: ""}, + }}} + + Expect(installer.OpenFgaPcAppsValues(configWith(nil, ""), vault)).To(BeNil()) + }) +}) diff --git a/internal/installer/package.go b/internal/installer/package.go index 7713f700c..8b060a992 100644 --- a/internal/installer/package.go +++ b/internal/installer/package.go @@ -54,7 +54,11 @@ func (p *Package) FileIO() util.FileIO { // GetWorkDir returns the working directory path for the package // by joining the OmsWorkdir and the filename (without the .tar.gz extension). func (p *Package) GetWorkDir() string { - return path.Join(p.OmsWorkdir, strings.ReplaceAll(p.Filename, ".tar.gz", "")) + filename := filepath.Base(p.Filename) + filename = strings.TrimSuffix(filename, ".tar.gz") + filename = strings.TrimSuffix(filename, ".tgz") + + return path.Join(p.OmsWorkdir, filename) } // GetDependencyPath returns the full path to a dependency file within the package's deps directory. diff --git a/internal/installer/package_copy.go b/internal/installer/package_copy.go new file mode 100644 index 000000000..304c1f823 --- /dev/null +++ b/internal/installer/package_copy.go @@ -0,0 +1,140 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/codesphere-cloud/oms/internal/installer/bom" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/logs" + "github.com/google/go-containerregistry/pkg/name" +) + +// PackageArtifact describes one image or OCI Helm chart transfer. +type PackageArtifact struct { + Source string + Destination string +} + +// ArtifactCopier copies an image or OCI artifact between registries. +type ArtifactCopier interface { + Copy(ctx context.Context, source, destination string) error +} + +// CraneArtifactCopier uses go-containerregistry's crane package for transfers. +type CraneArtifactCopier struct { + Insecure bool +} + +// Copy transfers one remote image or OCI artifact with crane. +func (c *CraneArtifactCopier) Copy(ctx context.Context, source, destination string) error { + options := []crane.Option{crane.WithContext(ctx), crane.WithNondistributable()} + if c.Insecure { + options = append(options, crane.Insecure) + } + if err := crane.Copy(source, destination, options...); err != nil { + return fmt.Errorf("crane copy failed: %w", err) + } + + return nil +} + +// ReadPackageArtifacts reads all images and OCI Helm charts from a BOM and +// builds their destination references. The original repository path is kept +// below dest so repositories with the same basename cannot collide. +func ReadPackageArtifacts(bomPath, dest string) ([]PackageArtifact, error) { + bomConfig, err := bom.Parse(bomPath) + if err != nil { + return nil, fmt.Errorf("failed to parse BOM: %w", err) + } + + references := bomConfig.GetOCIArtifacts() + + artifacts := make([]PackageArtifact, 0, len(references)) + for _, source := range references { + destination, err := PackageArtifactDestination(source, dest) + if err != nil { + return nil, err + } + + artifacts = append(artifacts, PackageArtifact{ + Source: strings.TrimPrefix(source, "oci://"), + Destination: destination, + }) + } + + return artifacts, nil +} + +// PackageArtifactDestination maps a source reference below a destination +// registry or repository prefix while preserving its tag or digest. +func PackageArtifactDestination(source, dest string) (string, error) { + source = strings.TrimPrefix(source, "oci://") + + dest = strings.TrimSuffix(strings.TrimPrefix(dest, "oci://"), "/") + if dest == "" { + return "", fmt.Errorf("destination registry must not be empty") + } + + sourceRef, err := name.ParseReference(source) + if err != nil { + return "", fmt.Errorf("invalid package artifact reference %q: %w", source, err) + } + + separator := ":" + if _, ok := sourceRef.(name.Digest); ok { + separator = "@" + } + + candidate := dest + "/" + sourceRef.Context().RepositoryStr() + separator + sourceRef.Identifier() + + destinationRef, err := name.ParseReference(candidate) + if err != nil { + return "", fmt.Errorf("invalid destination reference %q: %w", candidate, err) + } + + return destinationRef.Name(), nil +} + +// CopyPackageArtifacts transfers the prepared package artifacts in order, +// printing a single updating progress bar. Crane's own log output is +// captured rather than written to the terminal, so it doesn't clutter the +// progress bar; it's only surfaced if a copy fails. +func CopyPackageArtifacts(ctx context.Context, copier ArtifactCopier, artifacts []PackageArtifact) error { + total := len(artifacts) + start := time.Now() + + var craneOutput bytes.Buffer + logs.Warn.SetOutput(&craneOutput) + logs.Progress.SetOutput(&craneOutput) + defer func() { + logs.Warn.SetOutput(io.Discard) + logs.Progress.SetOutput(io.Discard) + }() + + for i, artifact := range artifacts { + craneOutput.Reset() + fmt.Printf("\r\033[2K%3d%% (%d/%d) %s %s -> %s", i*100/max(total, 1), i, total, time.Since(start).Round(time.Second), artifact.Source, artifact.Destination) + + if err := copier.Copy(ctx, artifact.Source, artifact.Destination); err != nil { + fmt.Println() + if craneOutput.Len() > 0 { + fmt.Fprint(os.Stderr, craneOutput.String()) + } + + return fmt.Errorf("failed to copy %s to %s: %w", artifact.Source, artifact.Destination, err) + } + } + + fmt.Printf("\r\033[2KCopied %d artifacts in %s\n", total, time.Since(start).Round(time.Second)) + + return nil +} diff --git a/internal/installer/package_copy_test.go b/internal/installer/package_copy_test.go new file mode 100644 index 000000000..81db16a5f --- /dev/null +++ b/internal/installer/package_copy_test.go @@ -0,0 +1,98 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "context" + "errors" + "os" + "path/filepath" + + "github.com/codesphere-cloud/oms/internal/installer" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type recordingArtifactCopier struct { + copies []installer.PackageArtifact + err error +} + +func (c *recordingArtifactCopier) Copy(_ context.Context, source, destination string) error { + c.copies = append(c.copies, installer.PackageArtifact{Source: source, Destination: destination}) + return c.err +} + +var _ = Describe("Package artifact copying", func() { + Describe("PackageArtifactDestination", func() { + It("preserves the source repository path and tag below the destination", func() { + destination, err := installer.PackageArtifactDestination( + "oci://ghcr.io/codesphere-cloud/charts/pc-apps:1.2.3", + "oci://registry.example.com/private-cloud/", + ) + + Expect(err).NotTo(HaveOccurred()) + Expect(destination).To(Equal("registry.example.com/private-cloud/codesphere-cloud/charts/pc-apps:1.2.3")) + }) + + It("preserves digests", func() { + destination, err := installer.PackageArtifactDestination( + "ghcr.io/codesphere-cloud/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "registry.example.com/mirror", + ) + + Expect(err).NotTo(HaveOccurred()) + Expect(destination).To(Equal("registry.example.com/mirror/codesphere-cloud/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) + }) + + It("rejects an empty destination", func() { + _, err := installer.PackageArtifactDestination("ghcr.io/codesphere/api:v1", "") + Expect(err).To(MatchError("destination registry must not be empty")) + }) + }) + + Describe("ReadPackageArtifacts", func() { + It("reads images and charts and prepares a stable transfer plan", func() { + tempDir := GinkgoT().TempDir() + bomPath := filepath.Join(tempDir, "bom.json") + Expect(os.WriteFile(bomPath, []byte(`{ + "components": { + "codesphere": { + "containerImages": {"api": "ghcr.io/codesphere/api:v1"}, + "files": {"chart": {"ociRef": "oci://ghcr.io/codesphere/charts/app:v1"}} + } + } + }`), 0644)).To(Succeed()) + + artifacts, err := installer.ReadPackageArtifacts(bomPath, "registry.example.com/mirror") + Expect(err).NotTo(HaveOccurred()) + Expect(artifacts).To(Equal([]installer.PackageArtifact{ + {Source: "ghcr.io/codesphere/api:v1", Destination: "registry.example.com/mirror/codesphere/api:v1"}, + {Source: "ghcr.io/codesphere/charts/app:v1", Destination: "registry.example.com/mirror/codesphere/charts/app:v1"}, + })) + }) + }) + + Describe("CopyPackageArtifacts", func() { + It("copies all artifacts in order", func() { + copier := &recordingArtifactCopier{} + artifacts := []installer.PackageArtifact{ + {Source: "source/one:v1", Destination: "dest/one:v1"}, + {Source: "source/two:v2", Destination: "dest/two:v2"}, + } + + Expect(installer.CopyPackageArtifacts(context.Background(), copier, artifacts)).To(Succeed()) + Expect(copier.copies).To(Equal(artifacts)) + }) + + It("adds source and destination context to copy failures", func() { + copier := &recordingArtifactCopier{err: errors.New("denied")} + err := installer.CopyPackageArtifacts(context.Background(), copier, []installer.PackageArtifact{ + {Source: "source/one:v1", Destination: "dest/one:v1"}, + }) + + Expect(err).To(MatchError("failed to copy source/one:v1 to dest/one:v1: denied")) + }) + }) +}) diff --git a/internal/installer/pc_apps.go b/internal/installer/pc_apps.go index a8d42de68..9eddbe1c5 100644 --- a/internal/installer/pc_apps.go +++ b/internal/installer/pc_apps.go @@ -80,17 +80,17 @@ func NewPCApps(c client.Client, version, namespace string, valuesFiles []string, }, nil } -func NewPcAppsFromBom(c client.Client, bomPath string, namespace string, valuesFiles []string, valuesOverride map[string]interface{}) (*PCApps, error) { +// NewPcAppsFromBom creates a PCApps installer from the pc-applications entry in a BOM. +func NewPcAppsFromBom(c client.Client, bomConfig *bom.Config, namespace string, valuesFiles []string, valuesOverride map[string]interface{}) (*PCApps, error) { if err := checkArgoCDScheme(c); err != nil { return nil, err } - bomCfg, err := bom.Parse(bomPath) - if err != nil { - return nil, fmt.Errorf("failed to parse bom.json: %w", err) + if bomConfig == nil { + return nil, fmt.Errorf("BOM is required") } - pcApps, ok := bomCfg.GetPCApps() + pcApps, ok := bomConfig.GetPCApps() if !ok { return nil, fmt.Errorf("pc-applications component not found in BOM") } diff --git a/internal/installer/secrets/secrets.go b/internal/installer/secrets/secrets.go index d7660c3e2..176f9762d 100644 --- a/internal/installer/secrets/secrets.go +++ b/internal/installer/secrets/secrets.go @@ -223,11 +223,10 @@ func EnsureNixSigningKeys(vault *files.InstallVault, host string) error { } // EnsureDefaultSecrets sets dummy defaults for all Helm chart secrets not managed by -// the installer config. Always overwrites digitalOceanApiToken; all others are only -// set when absent. +// the installer config. Idempotent: a value the vault already holds is kept. func EnsureDefaultSecrets(vault *files.InstallVault) error { - // Always overwrite — not used in private cloud but must not be empty. - setPassword(vault, files.SecretDigitalOceanApiToken, "dummy") + // Unused in private cloud, but the chart does not render without a value. + setPasswordIfEmpty(vault, files.SecretDigitalOceanApiToken, "dummy") for _, name := range optionalPasswordSecrets { setPasswordIfAbsent(vault, name, "dummy") @@ -300,6 +299,16 @@ func setPassword(vault *files.InstallVault, name, password string) { }) } +// setPasswordIfEmpty fills in a secret the vault does not have, or has without a value. +// Used for secrets the Helm chart needs a value for, where an empty entry is as good as none. +func setPasswordIfEmpty(vault *files.InstallVault, name, password string) { + if secret := vault.GetSecret(name); secret != nil && secret.Fields != nil && secret.Fields.Password != "" { + return + } + + setPassword(vault, name, password) +} + func setPasswordIfAbsent(vault *files.InstallVault, name, password string) { if vault.GetSecret(name) != nil { return diff --git a/internal/installer/secrets/secrets_test.go b/internal/installer/secrets/secrets_test.go index d19ee0f59..4bccfab54 100644 --- a/internal/installer/secrets/secrets_test.go +++ b/internal/installer/secrets/secrets_test.go @@ -177,10 +177,20 @@ var _ = Describe("EnsureNixSigningKeys", func() { }) var _ = Describe("EnsureDefaultSecrets", func() { - It("always overwrites digitalOceanApiToken", func() { + It("keeps a digitalOceanApiToken the vault already holds", func() { vault := newVault() vault.SetSecret(files.SecretEntry{Name: "digitalOceanApiToken", Fields: &files.SecretFields{Password: "real-token"}}) + Expect(secrets.EnsureDefaultSecrets(vault)).To(Succeed()) + Expect(vault.GetSecret("digitalOceanApiToken").Fields.Password).To(Equal("real-token")) + }) + + // The chart does not render without a value, so an entry that is there but empty is + // filled in like a missing one. + It("fills in an empty digitalOceanApiToken", func() { + vault := newVault() + vault.SetSecret(files.SecretEntry{Name: "digitalOceanApiToken", Fields: &files.SecretFields{Password: ""}}) + Expect(secrets.EnsureDefaultSecrets(vault)).To(Succeed()) Expect(vault.GetSecret("digitalOceanApiToken").Fields.Password).To(Equal("dummy")) }) diff --git a/internal/installer/vault/internal/filebackend/file.go b/internal/installer/vault/internal/filebackend/file.go new file mode 100644 index 000000000..85c7caabc --- /dev/null +++ b/internal/installer/vault/internal/filebackend/file.go @@ -0,0 +1,130 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package filebackend contains implementation details shared by file-backed vaults. +package filebackend + +import ( + "fmt" + "path/filepath" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" + "go.yaml.in/yaml/v3" +) + +// Options contains configuration shared by file-backed vault implementations. +type Options struct { + Path string + WithComments bool + FileIO util.FileIO +} + +// WithDefaults fills optional file backend dependencies. +func WithDefaults(opts Options) Options { + if opts.FileIO == nil { + opts.FileIO = util.NewFilesystemWriter() + } + + return opts +} + +// Marshal serializes install vault data for storage. +func Marshal(data *files.InstallVault, comments bool) ([]byte, error) { + if data == nil { + data = &files.InstallVault{} + } + + plain, err := data.Marshal() + if err != nil { + return nil, fmt.Errorf("failed to marshal vault: %w", err) + } + + if comments { + plain = append([]byte("# Codesphere Installer Secrets\n# Generated by OMS CLI\n\n"), plain...) + } + + return plain, nil +} + +// Write atomically writes vault data to path. +func Write(fileIO util.FileIO, path string, data []byte) error { + if err := fileIO.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("failed to create vault directory: %w", err) + } + + tmpPath, err := fileIO.CreateTemp(filepath.Dir(path), ".vault-*") + if err != nil { + return fmt.Errorf("failed to create temporary vault: %w", err) + } + defer func() { _ = fileIO.Remove(tmpPath) }() + + if err := fileIO.WriteFile(tmpPath, data, 0600); err != nil { + return fmt.Errorf("failed to write vault: %w", err) + } + + if err := fileIO.Rename(tmpPath, path); err != nil { + return fmt.Errorf("failed to replace vault: %w", err) + } + + return nil +} + +// IsSOPSEncryptedYAML reports whether a YAML document contains SOPS metadata. +func IsSOPSEncryptedYAML(data []byte) (bool, error) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return false, fmt.Errorf("failed to inspect YAML for SOPS metadata: %w", err) + } + + if len(doc.Content) == 0 { + return false, nil + } + + root := doc.Content[0] + if root.Kind != yaml.MappingNode { + return false, nil + } + + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == "sops" && root.Content[i+1].Kind == yaml.MappingNode { + return true, nil + } + } + + return false, nil +} + +// Parse unmarshals vault YAML, including SOPS whole-file wrapper output. +func Parse(data []byte) (*files.InstallVault, error) { + data = unwrapSOPSData(data) + + vault := &files.InstallVault{} + if err := vault.Unmarshal(data); err != nil { + return nil, fmt.Errorf("failed to unmarshal vault data: %w", err) + } + + return vault, nil +} + +// unwrapSOPSData strips a top-level data literal-block wrapper when present. +func unwrapSOPSData(data []byte) []byte { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil || len(doc.Content) == 0 { + return data + } + + root := doc.Content[0] + if root.Kind != yaml.MappingNode || len(root.Content) != 2 { + return data + } + + keyNode := root.Content[0] + + valueNode := root.Content[1] + if keyNode.Value != "data" || valueNode.Kind != yaml.ScalarNode { + return data + } + + return []byte(valueNode.Value) +} diff --git a/internal/installer/vault/internal/filebackend/file_test.go b/internal/installer/vault/internal/filebackend/file_test.go new file mode 100644 index 000000000..2aa2ee81d --- /dev/null +++ b/internal/installer/vault/internal/filebackend/file_test.go @@ -0,0 +1,35 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package filebackend + +import "testing" + +func TestParseUnwrapsSOPSWholeFileData(t *testing.T) { + data, err := Parse([]byte("data: |\n secrets:\n - name: foo\n fields:\n password: bar\n")) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if len(data.Secrets) != 1 || data.Secrets[0].Name != "foo" || data.Secrets[0].Fields.Password != "bar" { + t.Fatalf("Parse() = %#v", data) + } +} + +func TestUnwrapSOPSDataLeavesOtherDocumentsUnchanged(t *testing.T) { + tests := map[string]string{ + "plain vault": "secrets:\n - name: foo\n", + "empty document": "", + "multiple root keys": "data: some-value\nsops:\n key: val\n", + "invalid YAML": "not: valid: yaml: [[", + "non-scalar data": "data:\n nested: value\n", + } + + for name, input := range tests { + t.Run(name, func(t *testing.T) { + if got := string(unwrapSOPSData([]byte(input))); got != input { + t.Fatalf("unwrapSOPSData() = %q, want %q", got, input) + } + }) + } +} diff --git a/internal/installer/vault/mocks.go b/internal/installer/vault/mocks.go deleted file mode 100644 index 75bd5c16d..000000000 --- a/internal/installer/vault/mocks.go +++ /dev/null @@ -1,198 +0,0 @@ -// Code generated by mockery; DO NOT EDIT. -// github.com/vektra/mockery -// template: testify - -package vault - -import ( - mock "github.com/stretchr/testify/mock" -) - -// NewMockEncryptor creates a new instance of MockEncryptor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockEncryptor(t interface { - mock.TestingT - Cleanup(func()) -}) *MockEncryptor { - mock := &MockEncryptor{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockEncryptor is an autogenerated mock type for the Encryptor type -type MockEncryptor struct { - mock.Mock -} - -type MockEncryptor_Expecter struct { - mock *mock.Mock -} - -func (_m *MockEncryptor) EXPECT() *MockEncryptor_Expecter { - return &MockEncryptor_Expecter{mock: &_m.Mock} -} - -// Encrypt provides a mock function for the type MockEncryptor -func (_mock *MockEncryptor) Encrypt(src string, target string, recipient string) error { - ret := _mock.Called(src, target, recipient) - - if len(ret) == 0 { - panic("no return value specified for Encrypt") - } - - var r0 error - if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = returnFunc(src, target, recipient) - } else { - r0 = ret.Error(0) - } - return r0 -} - -// MockEncryptor_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt' -type MockEncryptor_Encrypt_Call struct { - *mock.Call -} - -// Encrypt is a helper method to define mock.On call -// - src string -// - target string -// - recipient string -func (_e *MockEncryptor_Expecter) Encrypt(src any, target any, recipient any) *MockEncryptor_Encrypt_Call { - return &MockEncryptor_Encrypt_Call{Call: _e.mock.On("Encrypt", src, target, recipient)} -} - -func (_c *MockEncryptor_Encrypt_Call) Run(run func(src string, target string, recipient string)) *MockEncryptor_Encrypt_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 string - if args[0] != nil { - arg0 = args[0].(string) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockEncryptor_Encrypt_Call) Return(err error) *MockEncryptor_Encrypt_Call { - _c.Call.Return(err) - return _c -} - -func (_c *MockEncryptor_Encrypt_Call) RunAndReturn(run func(src string, target string, recipient string) error) *MockEncryptor_Encrypt_Call { - _c.Call.Return(run) - return _c -} - -// NewMockAgeKeyResolver creates a new instance of MockAgeKeyResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockAgeKeyResolver(t interface { - mock.TestingT - Cleanup(func()) -}) *MockAgeKeyResolver { - mock := &MockAgeKeyResolver{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} - -// MockAgeKeyResolver is an autogenerated mock type for the AgeKeyResolver type -type MockAgeKeyResolver struct { - mock.Mock -} - -type MockAgeKeyResolver_Expecter struct { - mock *mock.Mock -} - -func (_m *MockAgeKeyResolver) EXPECT() *MockAgeKeyResolver_Expecter { - return &MockAgeKeyResolver_Expecter{mock: &_m.Mock} -} - -// Resolve provides a mock function for the type MockAgeKeyResolver -func (_mock *MockAgeKeyResolver) Resolve(explicitKeyFile string, fallbackDir string) (string, string, error) { - ret := _mock.Called(explicitKeyFile, fallbackDir) - - if len(ret) == 0 { - panic("no return value specified for Resolve") - } - - var r0 string - var r1 string - var r2 error - if returnFunc, ok := ret.Get(0).(func(string, string) (string, string, error)); ok { - return returnFunc(explicitKeyFile, fallbackDir) - } - if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { - r0 = returnFunc(explicitKeyFile, fallbackDir) - } else { - r0 = ret.Get(0).(string) - } - if returnFunc, ok := ret.Get(1).(func(string, string) string); ok { - r1 = returnFunc(explicitKeyFile, fallbackDir) - } else { - r1 = ret.Get(1).(string) - } - if returnFunc, ok := ret.Get(2).(func(string, string) error); ok { - r2 = returnFunc(explicitKeyFile, fallbackDir) - } else { - r2 = ret.Error(2) - } - return r0, r1, r2 -} - -// MockAgeKeyResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' -type MockAgeKeyResolver_Resolve_Call struct { - *mock.Call -} - -// Resolve is a helper method to define mock.On call -// - explicitKeyFile string -// - fallbackDir string -func (_e *MockAgeKeyResolver_Expecter) Resolve(explicitKeyFile any, fallbackDir any) *MockAgeKeyResolver_Resolve_Call { - return &MockAgeKeyResolver_Resolve_Call{Call: _e.mock.On("Resolve", explicitKeyFile, fallbackDir)} -} - -func (_c *MockAgeKeyResolver_Resolve_Call) Run(run func(explicitKeyFile string, fallbackDir string)) *MockAgeKeyResolver_Resolve_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 string - if args[0] != nil { - arg0 = args[0].(string) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockAgeKeyResolver_Resolve_Call) Return(recipient string, keyPath string, err error) *MockAgeKeyResolver_Resolve_Call { - _c.Call.Return(recipient, keyPath, err) - return _c -} - -func (_c *MockAgeKeyResolver_Resolve_Call) RunAndReturn(run func(explicitKeyFile string, fallbackDir string) (string, string, error)) *MockAgeKeyResolver_Resolve_Call { - _c.Call.Return(run) - return _c -} diff --git a/internal/installer/vault/plain/plain.go b/internal/installer/vault/plain/plain.go new file mode 100644 index 000000000..e71f7816e --- /dev/null +++ b/internal/installer/vault/plain/plain.go @@ -0,0 +1,87 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package plain implements an unencrypted file-backed vault. +package plain + +import ( + "errors" + "fmt" + "io/fs" + "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/vault/internal/filebackend" + "github.com/codesphere-cloud/oms/internal/util" +) + +// Options configures a plain file-backed vault. +type Options struct { + Path string + WithComments bool + FileIO util.FileIO +} + +// PlainVault stores vault YAML as an unencrypted file. +// +//revive:disable-next-line:exported // The backend-specific name is intentional. +type PlainVault struct{ options filebackend.Options } + +// New creates a plain file-backed vault. +func New(opts Options) (*PlainVault, error) { + if strings.TrimSpace(opts.Path) == "" { + return nil, fmt.Errorf("plain vault requires a file path") + } + + fileOpts := filebackend.WithDefaults(filebackend.Options(opts)) + + return &PlainVault{options: fileOpts}, nil +} + +// Load reads the unencrypted vault file and verifies it is not SOPS-encrypted. +func (v *PlainVault) Load() (*files.InstallVault, error) { + data, err := v.options.FileIO.ReadFile(v.options.Path) + if err != nil { + return nil, fmt.Errorf("failed to read vault file %s: %w", v.options.Path, err) + } + + encrypted, err := filebackend.IsSOPSEncryptedYAML(data) + if err != nil { + return nil, fmt.Errorf("failed to inspect vault file %s: %w", v.options.Path, err) + } + + if encrypted { + return nil, fmt.Errorf("vault file %s is SOPS-encrypted, but vault type is %q", v.options.Path, "plain") + } + + result, err := filebackend.Parse(data) + if err != nil { + return nil, fmt.Errorf("failed to parse vault file %s: %w", v.options.Path, err) + } + + return result, nil +} + +// LoadOrCreate loads the vault or returns an empty vault if the file does not exist. +func (v *PlainVault) LoadOrCreate() (*files.InstallVault, error) { + result, err := v.Load() + if errors.Is(err, fs.ErrNotExist) { + return &files.InstallVault{}, nil + } + + return result, err +} + +// Save writes vault data to an unencrypted file. +func (v *PlainVault) Save(data *files.InstallVault) error { + plain, err := filebackend.Marshal(data, v.options.WithComments) + if err != nil { + return fmt.Errorf("failed to marshal plain vault: %w", err) + } + + if err := filebackend.Write(v.options.FileIO, v.options.Path, plain); err != nil { + return fmt.Errorf("failed to write plain vault: %w", err) + } + + return nil +} diff --git a/internal/installer/vault/sops/encryption.go b/internal/installer/vault/sops/encryption.go new file mode 100644 index 000000000..6cd2d025e --- /dev/null +++ b/internal/installer/vault/sops/encryption.go @@ -0,0 +1,185 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package sops + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "filippo.io/age" + "github.com/codesphere-cloud/oms/internal/util" + sopsage "github.com/getsops/sops/v3/age" +) + +var xdgConfigHome = "XDG_CONFIG_HOME" + +// ResolveAgeKey resolves an existing age key or generates one in fallbackDir. +func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPath string, err error) { + return resolveAgeKey(util.NewFilesystemWriter(), explicitKeyFile, fallbackDir) +} + +func resolveAgeKey(fileIO util.FileIO, explicitKeyFile, fallbackDir string) (recipient string, keyPath string, err error) { + if explicitKeyFile != "" { + recipient, err = readRecipientFromFile(fileIO, explicitKeyFile) + if err != nil { + return "", "", fmt.Errorf("failed to read age key from %s: %w", explicitKeyFile, err) + } + + return recipient, explicitKeyFile, nil + } + + if raw := os.Getenv(sopsage.SopsAgeKeyEnv); raw != "" { + recipient, err = parseAgeRecipient(strings.NewReader(raw)) + if err != nil { + return "", "", fmt.Errorf("failed to parse age key from SOPS_AGE_KEY environment variable: %w", err) + } + + return recipient, "", nil + } + + if keyFile := os.Getenv(sopsage.SopsAgeKeyFileEnv); keyFile != "" { + recipient, err = readRecipientFromFile(fileIO, keyFile) + if err != nil { + return "", "", fmt.Errorf("failed to read age key from %s: %w", keyFile, err) + } + + return recipient, keyFile, nil + } + + defaultPath, configErr := getUserConfigDir() + if configErr == nil { + defaultPath = filepath.Join(defaultPath, sopsage.SopsAgeKeyUserConfigPath) + + recipient, err = readRecipientFromFile(fileIO, defaultPath) + if err == nil { + return recipient, defaultPath, nil + } + + if !errors.Is(err, fs.ErrNotExist) { + return "", "", fmt.Errorf("failed to read age key from default location %s: %w", defaultPath, err) + } + } + + keyPath = filepath.Join(fallbackDir, "age_key.txt") + + recipient, err = readRecipientFromFile(fileIO, keyPath) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return "", "", fmt.Errorf("failed to read age key from fallback location %s: %w", keyPath, err) + } + + recipient, err = generateAgeKey(fileIO, keyPath) + if err != nil { + return "", "", fmt.Errorf("failed to generate age key: %w", err) + } + } + + return recipient, keyPath, nil +} + +func parseAgeRecipient(reader io.Reader) (string, error) { + ids, err := age.ParseIdentities(reader) + if err != nil { + return "", fmt.Errorf("failed to parse age identities from file: %w", err) + } + + if len(ids) == 0 { + return "", fmt.Errorf("no age identities found in file") + } + + if len(ids) > 1 { + return "", fmt.Errorf("multiple age identities found in file, expected only one") + } + + switch id := ids[0].(type) { + case *age.X25519Identity: + return id.Recipient().String(), nil + case *age.HybridIdentity: + return id.Recipient().String(), nil + default: + return "", fmt.Errorf("internal error: unexpected identity type: %T", id) + } +} + +func readRecipientFromFile(fileIO util.FileIO, path string) (string, error) { + data, err := fileIO.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read age key file %s: %w", path, err) + } + + return parseAgeRecipient(strings.NewReader(string(data))) +} + +func getUserConfigDir() (string, error) { + if runtime.GOOS == "darwin" { + if userConfigDir, ok := os.LookupEnv(xdgConfigHome); ok && userConfigDir != "" { + return userConfigDir, nil + } + } + + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to resolve user config directory: %w", err) + } + + return configDir, nil +} + +func generateAgeKey(fileIO util.FileIO, keyPath string) (string, error) { + if err := fileIO.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { + return "", fmt.Errorf("failed to create directory for age key: %w", err) + } + + cmd := exec.Command("age-keygen", "-o", keyPath) + + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("age-keygen failed: %w: %s", err, out) + } + + recipient, err := readRecipientFromFile(fileIO, keyPath) + if err != nil { + return "", fmt.Errorf("failed to read generated age key: %w", err) + } + + return recipient, nil +} + +// EncryptFile encrypts src with SOPS and age and writes ciphertext to target. +func EncryptFile(src, target, recipient string) error { + cmd := exec.Command("sops", "--encrypt", "--input-type", "yaml", "--age", recipient, "--output", target, src) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("sops encrypt failed: %w: %s", err, out) + } + + return nil +} + +// DecryptFile decrypts a SOPS-encrypted file and returns its plaintext. +func DecryptFile(src, keyPath string) ([]byte, error) { + cmd := exec.Command("sops", "--decrypt", "--input-type", "yaml", src) + if keyPath != "" { + cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+keyPath) + } + + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("sops decrypt failed: %s", string(exitErr.Stderr)) + } + + return nil, fmt.Errorf("sops decrypt failed: %w", err) + } + + return out, nil +} diff --git a/internal/installer/vault/sops/sops.go b/internal/installer/vault/sops/sops.go new file mode 100644 index 000000000..88f903923 --- /dev/null +++ b/internal/installer/vault/sops/sops.go @@ -0,0 +1,212 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package sops implements a SOPS-encrypted, age-backed vault. +package sops + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/vault/internal/filebackend" + "github.com/codesphere-cloud/oms/internal/util" + sopsage "github.com/getsops/sops/v3/age" +) + +// Options configures a SOPS-encrypted file-backed vault. +type Options struct { + Path string + AgeKey string + WithComments bool + FileIO util.FileIO +} + +// SopsVault stores vault YAML encrypted with SOPS and age. +// +//revive:disable-next-line:exported // The backend-specific name is intentional. +type SopsVault struct { + fileOptions filebackend.Options + ageKey string +} + +// New creates a SOPS-encrypted file-backed vault. +func New(opts Options) (*SopsVault, error) { + if strings.TrimSpace(opts.Path) == "" { + return nil, fmt.Errorf("SOPS vault requires a file path") + } + + if err := ValidateConfiguration(opts.AgeKey); err != nil { + return nil, err + } + + fileOpts := filebackend.WithDefaults(filebackend.Options{ + Path: opts.Path, WithComments: opts.WithComments, FileIO: opts.FileIO, + }) + + return &SopsVault{fileOptions: fileOpts, ageKey: opts.AgeKey}, nil +} + +// NewLazy creates a vault whose path and key configuration are validated on first use. +func NewLazy(opts Options) *SopsVault { + fileOpts := filebackend.WithDefaults(filebackend.Options{ + Path: opts.Path, WithComments: opts.WithComments, FileIO: opts.FileIO, + }) + + return &SopsVault{fileOptions: fileOpts, ageKey: opts.AgeKey} +} + +// Load reads, validates, decrypts, and parses the configured vault file. +func (v *SopsVault) Load() (*files.InstallVault, error) { + keyPath, err := v.getAgeKey() + if err != nil { + return nil, err + } + + data, err := v.fileOptions.FileIO.ReadFile(v.fileOptions.Path) + if err != nil { + return nil, fmt.Errorf("failed to read vault file %s: %w", v.fileOptions.Path, err) + } + + encrypted, err := filebackend.IsSOPSEncryptedYAML(data) + if err != nil { + return nil, fmt.Errorf("failed to inspect vault file %s: %w", v.fileOptions.Path, err) + } + + if !encrypted { + return nil, fmt.Errorf("vault file %s is not SOPS-encrypted", v.fileOptions.Path) + } + + plain, err := DecryptFile(v.fileOptions.Path, keyPath) + if err != nil { + return nil, fmt.Errorf("failed to decrypt vault file %s: %w", v.fileOptions.Path, err) + } + + result, err := filebackend.Parse(plain) + if err != nil { + return nil, fmt.Errorf("failed to parse decrypted vault file %s: %w", v.fileOptions.Path, err) + } + + return result, nil +} + +// LoadOrCreate loads the vault or returns an empty vault if the file does not exist. +func (v *SopsVault) LoadOrCreate() (*files.InstallVault, error) { + result, err := v.Load() + if errors.Is(err, fs.ErrNotExist) { + return &files.InstallVault{}, nil + } + + return result, err +} + +// Save encrypts and writes vault data to the configured path. +func (v *SopsVault) Save(data *files.InstallVault) error { + if _, err := v.getAgeKey(); err != nil { + return err + } + + recipient, _, err := resolveConfiguredAgeKey(v.fileOptions.FileIO, v.ageKey) + if err != nil { + return err + } + + plain, err := filebackend.Marshal(data, v.fileOptions.WithComments) + if err != nil { + return fmt.Errorf("failed to marshal SOPS vault: %w", err) + } + + if err := v.fileOptions.FileIO.MkdirAll(filepath.Dir(v.fileOptions.Path), 0700); err != nil { + return fmt.Errorf("failed to create vault directory: %w", err) + } + + plainPath, err := v.fileOptions.FileIO.CreateTemp(filepath.Dir(v.fileOptions.Path), ".vault-plaintext-*") + if err != nil { + return fmt.Errorf("failed to create temporary plaintext vault: %w", err) + } + defer func() { _ = v.fileOptions.FileIO.Remove(plainPath) }() + + if err := v.fileOptions.FileIO.WriteFile(plainPath, plain, 0600); err != nil { + return fmt.Errorf("failed to write temporary plaintext vault: %w", err) + } + + encryptedPath, err := v.fileOptions.FileIO.CreateTemp(filepath.Dir(v.fileOptions.Path), ".vault-encrypted-*") + if err != nil { + return fmt.Errorf("failed to create temporary encrypted vault: %w", err) + } + defer func() { _ = v.fileOptions.FileIO.Remove(encryptedPath) }() + + if err := EncryptFile(plainPath, encryptedPath, recipient); err != nil { + return err + } + + if err := v.fileOptions.FileIO.Chmod(encryptedPath, 0600); err != nil { + return fmt.Errorf("failed to set encrypted vault permissions: %w", err) + } + + if err := v.fileOptions.FileIO.Rename(encryptedPath, v.fileOptions.Path); err != nil { + return fmt.Errorf("failed to replace encrypted vault: %w", err) + } + + return nil +} + +func (v *SopsVault) getAgeKey() (string, error) { + if v.ageKey != "" { + return v.ageKey, nil + } + + if os.Getenv(sopsage.SopsAgeKeyEnv) != "" { + return "", nil + } + + if keyFile := os.Getenv(sopsage.SopsAgeKeyFileEnv); keyFile != "" { + return keyFile, nil + } + + return "", ValidateConfiguration("") +} + +// ValidateConfiguration ensures an age key is available for SOPS operations. +func ValidateConfiguration(ageKey string) error { + if ageKey != "" || os.Getenv(sopsage.SopsAgeKeyEnv) != "" || os.Getenv(sopsage.SopsAgeKeyFileEnv) != "" { + return nil + } + + return fmt.Errorf("SOPS vault requires an age key; set an age key argument or %s/%s", sopsage.SopsAgeKeyEnv, sopsage.SopsAgeKeyFileEnv) +} + +func resolveConfiguredAgeKey(fileIO util.FileIO, explicit string) (recipient, keyPath string, err error) { + if explicit != "" { + recipient, err := readRecipientFromFile(fileIO, explicit) + if err != nil { + return "", "", fmt.Errorf("failed to read age key from %s: %w", explicit, err) + } + + return recipient, explicit, nil + } + + if raw := os.Getenv(sopsage.SopsAgeKeyEnv); raw != "" { + recipient, err := parseAgeRecipient(strings.NewReader(raw)) + if err != nil { + return "", "", fmt.Errorf("failed to parse age key from %s: %w", sopsage.SopsAgeKeyEnv, err) + } + + return recipient, "", nil + } + + if keyFile := os.Getenv(sopsage.SopsAgeKeyFileEnv); keyFile != "" { + recipient, err := readRecipientFromFile(fileIO, keyFile) + if err != nil { + return "", "", fmt.Errorf("failed to read age key from %s: %w", keyFile, err) + } + + return recipient, keyFile, nil + } + + return "", "", fmt.Errorf("SOPS vault requires an age key") +} diff --git a/internal/installer/vault/vault.go b/internal/installer/vault/vault.go new file mode 100644 index 000000000..e9163a26b --- /dev/null +++ b/internal/installer/vault/vault.go @@ -0,0 +1,106 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package vault defines the installer vault interface and backend factory. +package vault + +import ( + "fmt" + "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/vault/plain" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" + "github.com/codesphere-cloud/oms/internal/util" +) + +// Type identifies the on-disk vault format. +type Type string + +// Supported Vault types +const ( + TypeSOPS Type = "sops" + TypePlain Type = "plain" + DefaultType = TypeSOPS +) + +// Vault is the persistence boundary for installer secrets. Callers work with +// InstallVault values and do not need to know how those values are represented +// or protected on disk. +type Vault interface { + Load() (*files.InstallVault, error) + LoadOrCreate() (*files.InstallVault, error) + Save(*files.InstallVault) error +} + +// Options contains the parameters currently accepted by the vault factory. +// The factory only forwards file parameters to implementations that use them; +// future non-file vaults can ignore Path and WithComments entirely. +type Options struct { + Path string + AgeKey string + WithComments bool + FileIO util.FileIO +} + +// ParseType validates a user supplied vault type. +func ParseType(value string) (Type, error) { + switch Type(strings.ToLower(strings.TrimSpace(value))) { + case "", TypeSOPS: + return TypeSOPS, nil + case TypePlain: + return TypePlain, nil + default: + return "", fmt.Errorf("unsupported vault type %q (must be %q or %q)", value, TypeSOPS, TypePlain) + } +} + +// ValidateConfiguration validates backend-specific, non-resource parameters. +// File paths are intentionally validated by the file-backed constructors. +func ValidateConfiguration(vaultType Type, ageKey string) error { + if vaultType != TypeSOPS { + return nil + } + + if err := sops.ValidateConfiguration(ageKey); err != nil { + return fmt.Errorf("failed to validate SOPS vault configuration: %w", err) + } + + return nil +} + +// New creates a vault implementation for the requested type. +func New(vaultType Type, opts Options) (Vault, error) { + switch vaultType { + case TypeSOPS: + backend, err := sops.New(sops.Options{ + Path: opts.Path, AgeKey: opts.AgeKey, WithComments: opts.WithComments, FileIO: opts.FileIO, + }) + if err != nil { + return nil, fmt.Errorf("failed to create SOPS vault: %w", err) + } + + return backend, nil + case TypePlain: + backend, err := plain.New(plain.Options{ + Path: opts.Path, WithComments: opts.WithComments, FileIO: opts.FileIO, + }) + if err != nil { + return nil, fmt.Errorf("failed to create plain vault: %w", err) + } + + return backend, nil + default: + return nil, fmt.Errorf("unsupported vault type %q", vaultType) + } +} + +// NewFromString parses vaultType and creates the matching implementation. +func NewFromString(vaultType string, opts Options) (Vault, error) { + t, err := ParseType(vaultType) + if err != nil { + return nil, err + } + + return New(t, opts) +} diff --git a/internal/installer/vault/vault_encryption.go b/internal/installer/vault/vault_encryption.go deleted file mode 100644 index b82be612d..000000000 --- a/internal/installer/vault/vault_encryption.go +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package vault - -import ( - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - "filippo.io/age" - sopsage "github.com/getsops/sops/v3/age" - "go.yaml.in/yaml/v3" -) - -var xdgConfigHome = "XDG_CONFIG_HOME" - -// Encryptor encrypts a plaintext vault for an age recipient. -// -//mockery:generate: true -type Encryptor interface { - Encrypt(src, target, recipient string) error -} - -// AgeKeyResolver finds the age recipient used to encrypt a vault. -// -//mockery:generate: true -type AgeKeyResolver interface { - Resolve(explicitKeyFile, fallbackDir string) (recipient, keyPath string, err error) -} - -// SOPSEncryptor encrypts vaults using SOPS and age. -type SOPSEncryptor struct{} - -func (SOPSEncryptor) Encrypt(src, target, recipient string) error { - return EncryptFileWithSOPS(src, target, recipient) -} - -// DefaultAgeKeyResolver resolves age keys from the standard SOPS locations. -type DefaultAgeKeyResolver struct{} - -func (DefaultAgeKeyResolver) Resolve(explicitKeyFile, fallbackDir string) (recipient, keyPath string, err error) { - return ResolveAgeKey(explicitKeyFile, fallbackDir) -} - -// ResolveAgeKey resolves an existing age key or generates a new one. -// -// When explicitKeyFile is non-empty it takes priority over everything else: the -// recipient is read directly from that file and it is returned as the key path. -// This lets callers thread an explicit --age-key-file through without mutating -// the process environment. -// -// Otherwise it checks (in order): -// 1. SOPS_AGE_KEY environment variable (raw key content) -// 2. SOPS_AGE_KEY_FILE environment variable (path to key file) -// 3. Default location: ~/.config/sops/age/keys.txt -// 4. Generate a new key and write it to /age_key.txt -// -// Returns the age public key (recipient) and the path to the key file (empty when -// the key was supplied via SOPS_AGE_KEY). -func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPath string, err error) { - // 0. Explicit key file – supplied by the caller, takes priority. - if explicitKeyFile != "" { - recipient, err = readRecipientFromFile(explicitKeyFile) - if err != nil { - return "", "", fmt.Errorf("failed to read age key from %s: %w", explicitKeyFile, err) - } - return recipient, explicitKeyFile, nil - } - - // 1. SOPS_AGE_KEY env var – contains raw key content. - if raw := os.Getenv(sopsage.SopsAgeKeyEnv); raw != "" { - recipient, err = parseAgeRecipient(strings.NewReader(raw)) - if err != nil { - return "", "", fmt.Errorf("failed to parse age key from SOPS_AGE_KEY environment variable: %w", err) - } - return recipient, "", nil - } - - // 2. SOPS_AGE_KEY_FILE env var. - if keyFile := os.Getenv(sopsage.SopsAgeKeyFileEnv); keyFile != "" { - recipient, err = readRecipientFromFile(keyFile) - if err != nil { - return "", "", fmt.Errorf("failed to read age key from %s: %w", keyFile, err) - } - return recipient, keyFile, nil - } - - // 3. Default location: ~/.config/sops/age/keys.txt. - defaultPath, configErr := getUserConfigDir() - if configErr == nil { - defaultPath = filepath.Join(defaultPath, sopsage.SopsAgeKeyUserConfigPath) - recipient, err = readRecipientFromFile(defaultPath) - if err == nil { - return recipient, defaultPath, nil - } - if !os.IsNotExist(err) { - return "", "", fmt.Errorf("failed to read age key from default location %s: %w", defaultPath, err) - } - } - - // 4. Generate a new key. - keyPath = filepath.Join(fallbackDir, "age_key.txt") - recipient, err = readRecipientFromFile(keyPath) - if err != nil { - if !os.IsNotExist(err) { - return "", "", fmt.Errorf("failed to read age key from fallback location %s: %w", keyPath, err) - } - recipient, err = generateAgeKey(keyPath) - if err != nil { - return "", "", fmt.Errorf("failed to generate age key: %w", err) - } - return recipient, keyPath, nil - } - return recipient, keyPath, nil -} - -// parseAgeRecipient extracts the public key from age key given by reader. -func parseAgeRecipient(reader io.Reader) (string, error) { - ids, err := age.ParseIdentities(reader) - if err != nil { - return "", fmt.Errorf("failed to parse age identities from file: %w", err) - } - if len(ids) == 0 { - return "", fmt.Errorf("no age identities found in file") - } - if len(ids) > 1 { - return "", fmt.Errorf("multiple age identities found in file, expected only one") - } - id := ids[0] - switch id := id.(type) { - case *age.X25519Identity: - return id.Recipient().String(), nil - case *age.HybridIdentity: - return id.Recipient().String(), nil - default: - return "", fmt.Errorf("internal error: unexpected identity type: %T", id) - } -} - -// readRecipientFromFile reads an age key file and extracts the public key. -func readRecipientFromFile(path string) (recipient string, err error) { - file, err := os.Open(path) - if err != nil { - return "", err - } - defer func() { - err = file.Close() - }() - return parseAgeRecipient(file) -} - -func getUserConfigDir() (string, error) { - if runtime.GOOS == "darwin" { - if userConfigDir, ok := os.LookupEnv(xdgConfigHome); ok && userConfigDir != "" { - return userConfigDir, nil - } - } - return os.UserConfigDir() -} - -// generateAgeKey generates a new age keypair and writes it to the given path. -// Returns the public key (recipient). -func generateAgeKey(keyPath string) (string, error) { - if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { - return "", fmt.Errorf("failed to create directory for age key: %w", err) - } - - cmd := exec.Command("age-keygen", "-o", keyPath) - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("age-keygen failed: %w: %s", err, out) - } - - recipient, err := readRecipientFromFile(keyPath) - if err != nil { - return "", fmt.Errorf("failed to read generated age key: %w", err) - } - return recipient, nil -} - -// EncryptFileWithSOPS encrypts src with SOPS+age and writes ciphertext to target. -func EncryptFileWithSOPS(src, target, recipient string) error { - cmd := exec.Command("sops", "--encrypt", "--input-type", "yaml", "--age", recipient, "--output", target, src) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("sops encrypt failed: %w: %s", err, out) - } - return nil -} - -// DecryptFileWithSOPS decrypts a SOPS-encrypted file and returns the plaintext bytes. -// If keyPath is non-empty, SOPS_AGE_KEY_FILE is set for the sops process. -func DecryptFileWithSOPS(src, keyPath string) ([]byte, error) { - cmd := exec.Command("sops", "--decrypt", "--input-type", "yaml", src) - if keyPath != "" { - cmd.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+keyPath) - } - - out, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return nil, fmt.Errorf("sops decrypt failed: %s", string(exitErr.Stderr)) - } - return nil, fmt.Errorf("sops decrypt failed: %w", err) - } - - return out, nil -} - -// unwrapSOPSData strips a top-level "data" literal block scalar wrapper if -// present. When SOPS encrypts with --input-type yaml, it -// wraps the entire document under a data: | key. -func unwrapSOPSData(data []byte) []byte { - var doc yaml.Node - if err := yaml.Unmarshal(data, &doc); err != nil { - return data - } - if len(doc.Content) == 0 { - return data - } - root := doc.Content[0] - if root.Kind != yaml.MappingNode || len(root.Content) != 2 { - return data - } - keyNode := root.Content[0] - valNode := root.Content[1] - if keyNode.Value != "data" || valNode.Kind != yaml.ScalarNode { - return data - } - // The scalar value is the inner YAML content. - return []byte(valNode.Value) -} diff --git a/internal/installer/vault/vault_encryption_test.go b/internal/installer/vault/vault_encryption_test.go index 7045a68fe..b2145f9d9 100644 --- a/internal/installer/vault/vault_encryption_test.go +++ b/internal/installer/vault/vault_encryption_test.go @@ -12,6 +12,7 @@ import ( . "github.com/onsi/gomega" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" ) func sopsAndAgeAvailable() bool { @@ -73,14 +74,14 @@ var _ = Describe("VaultEncryption", func() { // Set conflicting env vars to prove the explicit file takes priority. Expect(os.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(tmpDir, "ignored.txt"))).To(Succeed()) - recipient, keyPath, err := vault.ResolveAgeKey(keyFile, tmpDir) + recipient, keyPath, err := sops.ResolveAgeKey(keyFile, tmpDir) Expect(err).ToNot(HaveOccurred()) Expect(recipient).To(HavePrefix("age1")) Expect(keyPath).To(Equal(keyFile)) }) It("returns an error if the explicit file does not exist", func() { - _, _, err := vault.ResolveAgeKey(filepath.Join(tmpDir, "missing.txt"), tmpDir) + _, _, err := sops.ResolveAgeKey(filepath.Join(tmpDir, "missing.txt"), tmpDir) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to read age key")) }) @@ -111,7 +112,7 @@ var _ = Describe("VaultEncryption", func() { Expect(os.Setenv("SOPS_AGE_KEY", privKeyLine)).To(Succeed()) - recipient, keyPath, err := vault.ResolveAgeKey("", tmpDir) + recipient, keyPath, err := sops.ResolveAgeKey("", tmpDir) Expect(err).ToNot(HaveOccurred()) Expect(recipient).To(HavePrefix("age1")) Expect(keyPath).To(BeEmpty()) @@ -129,7 +130,7 @@ var _ = Describe("VaultEncryption", func() { Expect(os.Setenv("SOPS_AGE_KEY_FILE", keyFile)).To(Succeed()) - recipient, keyPath, err := vault.ResolveAgeKey("", tmpDir) + recipient, keyPath, err := sops.ResolveAgeKey("", tmpDir) Expect(err).ToNot(HaveOccurred()) Expect(recipient).To(HavePrefix("age1")) Expect(keyPath).To(Equal(keyFile)) @@ -138,7 +139,7 @@ var _ = Describe("VaultEncryption", func() { It("should return error if the file does not exist", func() { Expect(os.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(tmpDir, "nonexistent.txt"))).To(Succeed()) - _, _, err := vault.ResolveAgeKey("", tmpDir) + _, _, err := sops.ResolveAgeKey("", tmpDir) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to read age key")) }) @@ -150,7 +151,7 @@ var _ = Describe("VaultEncryption", func() { Skip("age-keygen not available") } - recipient, keyPath, err := vault.ResolveAgeKey("", tmpDir) + recipient, keyPath, err := sops.ResolveAgeKey("", tmpDir) Expect(err).ToNot(HaveOccurred()) Expect(recipient).To(HavePrefix("age1")) Expect(keyPath).To(Equal(filepath.Join(tmpDir, "age_key.txt"))) @@ -161,54 +162,7 @@ var _ = Describe("VaultEncryption", func() { }) }) - Describe("IsSOPSEncryptedFile", func() { - var tmpDir string - - BeforeEach(func() { - var err error - tmpDir, err = os.MkdirTemp("", "sops-detect-test-*") - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - Expect(os.RemoveAll(tmpDir)).To(Succeed()) - }) - - It("returns false for a file without sops metadata", func() { - path := filepath.Join(tmpDir, "plain.yaml") - Expect(os.WriteFile(path, []byte("key: value\n"), 0644)).To(Succeed()) - - encrypted, err := vault.IsSOPSEncryptedFile(path) - Expect(err).ToNot(HaveOccurred()) - Expect(encrypted).To(BeFalse()) - }) - - It("returns true for a file with sops top-level key", func() { - path := filepath.Join(tmpDir, "sops.yaml") - Expect(os.WriteFile(path, []byte("sops:\n age: age1abc\n"), 0644)).To(Succeed()) - - encrypted, err := vault.IsSOPSEncryptedFile(path) - Expect(err).ToNot(HaveOccurred()) - Expect(encrypted).To(BeTrue()) - }) - - It("returns false for an empty file", func() { - path := filepath.Join(tmpDir, "empty.yaml") - Expect(os.WriteFile(path, []byte{}, 0644)).To(Succeed()) - - encrypted, err := vault.IsSOPSEncryptedFile(path) - Expect(err).ToNot(HaveOccurred()) - Expect(encrypted).To(BeFalse()) - }) - - It("returns an error for a non-existent file", func() { - path := filepath.Join(tmpDir, "missing.yaml") - _, err := vault.IsSOPSEncryptedFile(path) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("LoadVaultData", func() { + Describe("file-backed vault loading", func() { var tmpDir string BeforeEach(func() { @@ -226,11 +180,13 @@ var _ = Describe("VaultEncryption", func() { plainYAML := "secrets:\n - name: test-secret\n fields:\n password: hunter2\n" Expect(os.WriteFile(vaultPath, []byte(plainYAML), 0644)).To(Succeed()) - vault, err := vault.LoadUnencryptedVaultData(vaultPath) + backend, err := vault.New(vault.TypePlain, vault.Options{Path: vaultPath}) Expect(err).ToNot(HaveOccurred()) - Expect(vault.Secrets).To(HaveLen(1)) - Expect(vault.Secrets[0].Name).To(Equal("test-secret")) - Expect(vault.Secrets[0].Fields.Password).To(Equal("hunter2")) + loaded, err := backend.Load() + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Secrets).To(HaveLen(1)) + Expect(loaded.Secrets[0].Name).To(Equal("test-secret")) + Expect(loaded.Secrets[0].Fields.Password).To(Equal("hunter2")) }) It("unwraps a plain file with data: | wrapper (SOPS whole-file format edge case)", func() { @@ -238,11 +194,13 @@ var _ = Describe("VaultEncryption", func() { wrappedYAML := "data: |\n secrets:\n - name: test-secret\n fields:\n password: hunter2\n" Expect(os.WriteFile(vaultPath, []byte(wrappedYAML), 0644)).To(Succeed()) - vault, err := vault.LoadUnencryptedVaultData(vaultPath) + backend, err := vault.New(vault.TypePlain, vault.Options{Path: vaultPath}) + Expect(err).ToNot(HaveOccurred()) + loaded, err := backend.Load() Expect(err).ToNot(HaveOccurred()) - Expect(vault.Secrets).To(HaveLen(1)) - Expect(vault.Secrets[0].Name).To(Equal("test-secret")) - Expect(vault.Secrets[0].Fields.Password).To(Equal("hunter2")) + Expect(loaded.Secrets).To(HaveLen(1)) + Expect(loaded.Secrets[0].Name).To(Equal("test-secret")) + Expect(loaded.Secrets[0].Fields.Password).To(Equal("hunter2")) }) It("loads and decrypts a SOPS-encrypted vault end-to-end", func() { @@ -256,7 +214,7 @@ var _ = Describe("VaultEncryption", func() { Expect(err).ToNot(HaveOccurred(), string(out)) // Extract the public key (recipient). - recipient, _, err := vault.ResolveAgeKey(ageKeyPath, tmpDir) + recipient, _, err := sops.ResolveAgeKey(ageKeyPath, tmpDir) Expect(err).ToNot(HaveOccurred()) // Write a plain vault file. @@ -271,16 +229,19 @@ var _ = Describe("VaultEncryption", func() { encOut, err := encryptCmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), string(encOut)) - // LoadVaultData should detect SOPS, decrypt, unwrap data: |, and parse. - vault, err := vault.LoadVaultData(vaultPath, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultPath, AgeKey: ageKeyPath}) Expect(err).ToNot(HaveOccurred()) - Expect(vault.Secrets).To(HaveLen(1)) - Expect(vault.Secrets[0].Name).To(Equal("sops-secret")) - Expect(vault.Secrets[0].Fields.Password).To(Equal("s3cr3t")) + loaded, err := backend.Load() + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Secrets).To(HaveLen(1)) + Expect(loaded.Secrets[0].Name).To(Equal("sops-secret")) + Expect(loaded.Secrets[0].Fields.Password).To(Equal("s3cr3t")) }) It("returns an error for a non-existent file", func() { - _, err := vault.LoadVaultData(filepath.Join(tmpDir, "missing.yaml"), "") + backend, err := vault.New(vault.TypePlain, vault.Options{Path: filepath.Join(tmpDir, "missing.yaml")}) + Expect(err).ToNot(HaveOccurred()) + _, err = backend.Load() Expect(err).To(HaveOccurred()) }) }) diff --git a/internal/installer/vault/vault_encryption_unexported_test.go b/internal/installer/vault/vault_encryption_unexported_test.go deleted file mode 100644 index e83e67cac..000000000 --- a/internal/installer/vault/vault_encryption_unexported_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package vault - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("unwrapSOPSData", func() { - It("returns data unchanged when there is no data: wrapper", func() { - input := []byte("secrets:\n - name: foo\n fields:\n password: bar\n") - output := unwrapSOPSData(input) - Expect(string(output)).To(Equal(string(input))) - }) - - It("strips a top-level data: | wrapper and returns inner content", func() { - input := []byte("data: |\n secrets:\n - name: foo\n fields:\n password: bar\n") - output := unwrapSOPSData(input) - Expect(string(output)).To(Equal("secrets:\n - name: foo\n fields:\n password: bar\n")) - }) - - It("returns data unchanged for an empty document", func() { - input := []byte("") - output := unwrapSOPSData(input) - Expect(string(output)).To(Equal(string(input))) - }) - - It("returns data unchanged when root has multiple keys", func() { - input := []byte("data: some-value\nsops:\n key: val\n") - output := unwrapSOPSData(input) - Expect(string(output)).To(Equal(string(input))) - }) - - It("returns data unchanged for invalid YAML", func() { - input := []byte("not: valid: yaml: [[") - output := unwrapSOPSData(input) - Expect(string(output)).To(Equal(string(input))) - }) - - It("returns data unchanged when data is not a scalar", func() { - input := []byte("data:\n nested: value\n") - output := unwrapSOPSData(input) - Expect(string(output)).To(Equal(string(input))) - }) -}) diff --git a/internal/installer/vault/vault_secret_creator.go b/internal/installer/vault/vault_secret_creator.go index 5ac825485..a0e40b97a 100644 --- a/internal/installer/vault/vault_secret_creator.go +++ b/internal/installer/vault/vault_secret_creator.go @@ -36,22 +36,28 @@ func NewVaultSecretCreator(c client.Client) *VaultSecretCreator { // - File entries produce a single key equal to the entry name. // - Field entries produce "entryName.password" and, when present, "entryName.username". func (v *VaultSecretCreator) CreateSecretFromFile(ctx context.Context, vaultFile, ageKeyPath, namespace, secretName string) error { - decrypted, err := DecryptFileWithSOPS(vaultFile, ageKeyPath) + backend, err := New(TypeSOPS, Options{Path: vaultFile, AgeKey: ageKeyPath}) if err != nil { - return fmt.Errorf("failed to decrypt vault file: %w", err) + return err } - vault := &files.InstallVault{} - if err := vault.Unmarshal(decrypted); err != nil { - return fmt.Errorf("failed to parse vault file: %w", err) + return v.CreateSecretFromStore(ctx, backend, namespace, secretName) +} + +// CreateSecretFromStore loads secrets through the abstract vault and syncs them +// to a Kubernetes secret. +func (v *VaultSecretCreator) CreateSecretFromStore(ctx context.Context, store Vault, namespace, secretName string) error { + data, err := store.Load() + if err != nil { + return fmt.Errorf("failed to load vault: %w", err) } // Always create new service accounts tokens during creation to ensure they are always valid and updated. - if err := secrets.EnsureServiceAccountTokens(vault); err != nil { + if err := secrets.EnsureServiceAccountTokens(data); err != nil { return fmt.Errorf("failed to ensure service account tokens: %w", err) } - return v.CreateSecretFromVault(ctx, vault, namespace, secretName) + return v.CreateSecretFromVault(ctx, data, namespace, secretName) } // CreateSecretFromVault creates or updates a Kubernetes secret with the contents of a Vault in the target cluster. diff --git a/internal/installer/vault/vault_store_test.go b/internal/installer/vault/vault_store_test.go new file mode 100644 index 000000000..e24257278 --- /dev/null +++ b/internal/installer/vault/vault_store_test.go @@ -0,0 +1,99 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vault_test + +import ( + "os" + "os/exec" + "path/filepath" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/installer/vault/plain" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Vault stores", func() { + It("round-trips secrets and secret files through a plain vault", func() { + path := filepath.Join(GinkgoT().TempDir(), "prod.vault.yaml") + store, err := plain.New(plain.Options{Path: path}) + Expect(err).NotTo(HaveOccurred()) + + want := &files.InstallVault{Secrets: []files.SecretEntry{ + {Name: "password", Fields: &files.SecretFields{Username: "user", Password: "secret"}}, + {Name: "certificate", File: &files.SecretFile{Name: "ca.pem", Content: "PEM"}}, + }} + Expect(store.Save(want)).To(Succeed()) + got, err := store.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetSecret("password").Fields.Password).To(Equal("secret")) + Expect(got.GetSecret("certificate").File.Content).To(Equal("PEM")) + + info, err := os.Stat(path) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + }) + + It("requires an age key for a SOPS vault", func() { + GinkgoT().Setenv("SOPS_AGE_KEY", "") + GinkgoT().Setenv("SOPS_AGE_KEY_FILE", "") + _, err := vault.New(vault.TypeSOPS, vault.Options{Path: filepath.Join(GinkgoT().TempDir(), "prod.vault.yaml")}) + Expect(err).To(HaveOccurred()) + }) + + It("round-trips secrets through a SOPS vault", func() { + if _, err := exec.LookPath("sops"); err != nil { + Skip("sops is not installed") + } + + if _, err := exec.LookPath("age-keygen"); err != nil { + Skip("age-keygen is not installed") + } + + dir := GinkgoT().TempDir() + keyPath := filepath.Join(dir, "age-key.txt") + output, err := exec.Command("age-keygen", "-o", keyPath).CombinedOutput() + Expect(err).NotTo(HaveOccurred(), string(output)) + + path := filepath.Join(dir, "prod.vault.yaml") + store, err := sops.New(sops.Options{Path: path, AgeKey: keyPath}) + Expect(err).NotTo(HaveOccurred()) + + want := &files.InstallVault{Secrets: []files.SecretEntry{{Name: "token", Fields: &files.SecretFields{Password: "secret"}}}} + Expect(store.Save(want)).To(Succeed()) + + onDisk, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(onDisk).NotTo(BeEmpty()) + + got, err := store.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetSecret("token").Fields.Password).To(Equal("secret")) + }) + + It("defaults an empty type to SOPS", func() { + vaultType, err := vault.ParseType("") + Expect(err).NotTo(HaveOccurred()) + Expect(vaultType).To(Equal(vault.TypeSOPS)) + }) + + It("validates file paths in the file-backed implementations", func() { + _, err := plain.New(plain.Options{}) + Expect(err).To(HaveOccurred()) + GinkgoT().Setenv("SOPS_AGE_KEY", "test-key-is-present") + + _, err = sops.New(sops.Options{}) + Expect(err).To(HaveOccurred()) + }) + + It("handles a missing plain file inside the plain vault", func() { + store, err := plain.New(plain.Options{Path: filepath.Join(GinkgoT().TempDir(), "missing.yaml")}) + Expect(err).NotTo(HaveOccurred()) + data, err := store.LoadOrCreate() + Expect(err).NotTo(HaveOccurred()) + Expect(data.Secrets).To(BeEmpty()) + }) +}) diff --git a/internal/installer/vault/vault_suite_test.go b/internal/installer/vault/vault_suite_test.go index 7ad67417d..a36330c24 100644 --- a/internal/installer/vault/vault_suite_test.go +++ b/internal/installer/vault/vault_suite_test.go @@ -1,7 +1,7 @@ // Copyright (c) Codesphere Inc. // SPDX-License-Identifier: Apache-2.0 -package vault +package vault_test import ( "testing" diff --git a/internal/installer/vault/vault_templating_secret_store.go b/internal/installer/vault/vault_templating_secret_store.go index 9a0865053..4511eda26 100644 --- a/internal/installer/vault/vault_templating_secret_store.go +++ b/internal/installer/vault/vault_templating_secret_store.go @@ -6,19 +6,17 @@ package vault import ( "errors" "fmt" - "os" "github.com/codesphere-cloud/oms/internal/installer/files" - "go.yaml.in/yaml/v3" + "github.com/codesphere-cloud/oms/internal/installer/vault/sops" ) // VaultTemplatingSecretStore resolves secrets referenced from config templates // against a SOPS-encrypted install vault. The vault can either be provided // directly or loaded lazily from disk on first lookup. type VaultTemplatingSecretStore struct { - vault *files.InstallVault - vaultPath string - ageKeyPath string + vault *files.InstallVault + backend Vault } // NewVaultTemplatingSecretStore returns a store backed by an already-decrypted vault. @@ -29,16 +27,27 @@ func NewVaultTemplatingSecretStore(vault *files.InstallVault) *VaultTemplatingSe // NewLazyVaultTemplatingSecretStore returns a store that decrypts and loads the // vault from vaultPath using ageKeyPath on the first secret lookup. func NewLazyVaultTemplatingSecretStore(vaultPath, ageKeyPath string) *VaultTemplatingSecretStore { + backend := sops.NewLazy(sops.Options{Path: vaultPath, AgeKey: ageKeyPath}) return &VaultTemplatingSecretStore{ - vaultPath: vaultPath, - ageKeyPath: ageKeyPath, + backend: backend, } } +// NewLazyVaultTemplatingSecretStoreWithVault returns a lazily loaded secret +// store backed by any Vault implementation. +func NewLazyVaultTemplatingSecretStoreWithVault(backend Vault) *VaultTemplatingSecretStore { + return &VaultTemplatingSecretStore{backend: backend} +} + // NewVaultTemplatingSecretStoreFromFile decrypts and loads the vault from // vaultPath using ageKeyPath and returns a store backed by it. func NewVaultTemplatingSecretStoreFromFile(vaultPath, ageKeyPath string) (*VaultTemplatingSecretStore, error) { - vault, err := LoadVaultData(vaultPath, ageKeyPath) + backend, err := New(TypeSOPS, Options{Path: vaultPath, AgeKey: ageKeyPath}) + if err != nil { + return nil, err + } + + vault, err := backend.Load() if err != nil { return nil, err } @@ -68,10 +77,12 @@ func (s *VaultTemplatingSecretStore) ensureVault() error { if s.vault != nil { return nil } - if s.vaultPath == "" { - return errors.New("vaultPath not set") + + if s.backend == nil { + return errors.New("vault backend not set") } - vault, err := LoadVaultData(s.vaultPath, s.ageKeyPath) + + vault, err := s.backend.Load() if err != nil { return err } @@ -111,106 +122,3 @@ func selectVaultSecretValue(entry files.SecretEntry, selector ...string) (string return "", fmt.Errorf("selector %q is not available on secret %q", field, entry.Name) } - -// LoadVaultData reads, SOPS-decrypts, and parses the vault at vaultPath using -// the age key at ageKeyPath, returning the decoded install vault. -func LoadVaultData(vaultPath, ageKeyPath string) (*files.InstallVault, error) { - data, err := os.ReadFile(vaultPath) - if err != nil { - return nil, fmt.Errorf("failed to read vault file %s: %w", vaultPath, err) - } - - encrypted, err := isSOPSEncryptedYAML(data) - if err != nil { - return nil, fmt.Errorf("failed to inspect vault file %s: %w", vaultPath, err) - } - - if !encrypted { - return nil, fmt.Errorf("vault file %s is not SOPS-encrypted", vaultPath) - } - - decryptedData, err := DecryptFileWithSOPS(vaultPath, ageKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to decrypt vault.yaml: %w", err) - } - - vault, err := parseVaultData(decryptedData) - if err != nil { - return nil, fmt.Errorf("failed to parse decrypted vault.yaml: %w", err) - } - - return vault, nil -} - -// LoadUnencryptedVaultData reads parses an unencrypted vault at vaultPath -// returning the decoded install vault. -// This is only used for GCP Bootstrapping. All other features should force a decrypted vault. -func LoadUnencryptedVaultData(vaultPath string) (*files.InstallVault, error) { - data, err := os.ReadFile(vaultPath) - if err != nil { - return nil, fmt.Errorf("failed to read vault file %s: %w", vaultPath, err) - } - - encrypted, err := isSOPSEncryptedYAML(data) - if err != nil { - return nil, fmt.Errorf("failed to inspect vault file %s: %w", vaultPath, err) - } - - if encrypted { - return nil, fmt.Errorf("failed to use unencrypted vault: vault is encrpted") - } - - vault, err := parseVaultData(data) - if err != nil { - return nil, fmt.Errorf("failed to parse decrypted vault.yaml: %w", err) - } - - return vault, nil -} - -// IsSOPSEncryptedFile checks whether the file at path is a SOPS-encrypted YAML document. -func IsSOPSEncryptedFile(path string) (bool, error) { - data, err := os.ReadFile(path) - if err != nil { - return false, err - } - return isSOPSEncryptedYAML(data) -} - -// isSOPSEncryptedYAML checks whether the YAML document contains SOPS metadata. -// SOPS-encrypted YAML files have a top-level "sops" mapping that stores -// encryption metadata such as age recipients, encrypted data keys, and MACs. -func isSOPSEncryptedYAML(data []byte) (bool, error) { - var doc yaml.Node - if err := yaml.Unmarshal(data, &doc); err != nil { - return false, err - } - if len(doc.Content) == 0 { - return false, nil - } - - root := doc.Content[0] - if root.Kind != yaml.MappingNode { - return false, nil - } - - // A mapping node stores its keys and values as a flat list alternating - // key, value, key, value, ... so we step by 2 to visit each key/value pair. - for i := 0; i+1 < len(root.Content); i += 2 { - if root.Content[i].Value == "sops" && root.Content[i+1].Kind == yaml.MappingNode { - return true, nil - } - } - - return false, nil -} - -func parseVaultData(data []byte) (*files.InstallVault, error) { - data = unwrapSOPSData(data) - - vault := &files.InstallVault{} - if err := vault.Unmarshal(data); err != nil { - return nil, err - } - return vault, nil -} diff --git a/internal/portal/download.go b/internal/portal/download.go new file mode 100644 index 000000000..3052b2818 --- /dev/null +++ b/internal/portal/download.go @@ -0,0 +1,73 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package portal + +import ( + "fmt" + "os" + + intutil "github.com/codesphere-cloud/oms/internal/util" +) + +// DownloadOptions configures DownloadAndVerifyBuild. +type DownloadOptions struct { + // Resume appends to an existing partial download at destination instead + // of always starting over. + Resume bool + // Quiet suppresses download progress output. + Quiet bool +} + +// DownloadAndVerifyBuild downloads the named artifact from build to destination +// and verifies its checksum. It is shared by the download and copy package +// commands so their download and verify behavior stays in sync. +func DownloadAndVerifyBuild(p Portal, fileWriter intutil.FileIO, product Product, build Build, filename, destination string, opts DownloadOptions) error { + download, err := build.GetBuildForDownload(filename) + if err != nil { + return fmt.Errorf("failed to find artifact in package: %w", err) + } + + out, err := openDestination(fileWriter, destination, opts.Resume) + if err != nil { + return fmt.Errorf("failed to open pckage destination file: %w", err) + } + defer intutil.CloseFileIgnoreError(out) + + fileSize := 0 + if opts.Resume { + if fileInfo, statErr := out.Stat(); statErr == nil { + fileSize = int(fileInfo.Size()) + } + } + + if err := p.DownloadBuildArtifact(product, download, out, fileSize, opts.Quiet); err != nil { + return fmt.Errorf("failed to download build: %w", err) + } + + verifyFile, err := fileWriter.Open(destination) + if err != nil { + return fmt.Errorf("failed to open %q: %w", destination, err) + } + defer intutil.CloseFileIgnoreError(verifyFile) + + if err := p.VerifyBuildArtifactDownload(verifyFile, download); err != nil { + return fmt.Errorf("failed to verify artifact: %w", err) + } + + return nil +} + +func openDestination(fileWriter intutil.FileIO, destination string, resume bool) (*os.File, error) { + if resume { + if out, err := fileWriter.OpenAppend(destination); err == nil { + return out, nil + } + } + + file, err := fileWriter.Create(destination) + if err != nil { + return nil, fmt.Errorf("failed to open file at %q: %w", destination, err) + } + return file, nil +} diff --git a/internal/portal/portal.go b/internal/portal/portal.go index 7b337e7d6..0947e024b 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -95,25 +95,24 @@ func (c *PortalClient) isOKResponseStatus(resp *http.Response) error { } if resp.StatusCode >= 300 { - log.Printf("Non-2xx response received from OMS-Portal (%s) - Status: %d", c.Env.GetOmsPortalApi(), resp.StatusCode) + var respBody string + if resp.Body != nil { + body, _ := io.ReadAll(resp.Body) + respBody = strings.TrimSpace(string(body)) + } - healthErr := c.GetHealth() - if healthErr != nil { - healthErr = fmt.Errorf("OMS-Portal healthcheck failed: %w", healthErr) - log.Println(healthErr.Error()) - log.Println("Please check if the OMS-Portal URL is correct and instance is healthy and reachable at:", c.Env.GetOmsPortalApi()) + log.Printf("Non-2xx response received from OMS-Portal (%s) - Status: %d, Body: %s", c.Env.GetOmsPortalApi(), resp.StatusCode, respBody) - return healthErr + if healthErr := c.GetHealth(); healthErr != nil { + log.Printf("OMS-Portal healthcheck also failed: %s", healthErr) + log.Println("Please check if the OMS-Portal URL is correct and instance is healthy and reachable at:", c.Env.GetOmsPortalApi()) } - healthyPortalLog := fmt.Sprintf("OMS-Portal is healthy and reachable, but returned an error response - Status: %d", resp.StatusCode) - if resp.Body != nil { - respBody, _ := io.ReadAll(resp.Body) - healthyPortalLog = fmt.Sprintf("%s, Body: %s", healthyPortalLog, string(respBody)) + if respBody == "" { + return fmt.Errorf("OMS-Portal returned status %d with an empty response body", resp.StatusCode) } - log.Println(healthyPortalLog) - return fmt.Errorf("%s", healthyPortalLog) + return fmt.Errorf("OMS-Portal returned status %d: %s", resp.StatusCode, respBody) } return nil diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index 8797da67a..ef51e5e6d 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -121,7 +121,7 @@ var _ = Describe("PortalClient", func() { }) Context("OMS-Portal Health Check is OK", func() { - It("returns no response and an error showing portal is healthy", func() { + It("returns no response and an error containing the response body", func() { mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( func(req *http.Request) (*http.Response, error) { if strings.Contains(req.URL.Path, "health") { @@ -135,6 +135,7 @@ var _ = Describe("PortalClient", func() { return &http.Response{ StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("owner must be a valid email address")), }, nil }) @@ -143,17 +144,19 @@ var _ = Describe("PortalClient", func() { resp, err := client.AuthorizedHttpRequest(testRequest) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("OMS-Portal is healthy and reachable, but returned an error response")) + Expect(err.Error()).To(ContainSubstring("OMS-Portal returned status 404")) + Expect(err.Error()).To(ContainSubstring("owner must be a valid email address")) Expect(resp).To(BeNil()) }) }) Context("OMS-Portal Health Check is not OK", func() { - It("returns no response and an error showing portal is unhealthy", func() { + It("still returns the response body from the original request", func() { mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("owner must be a valid email address")), }, nil }) @@ -162,7 +165,8 @@ var _ = Describe("PortalClient", func() { resp, err := client.AuthorizedHttpRequest(testRequest) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("OMS-Portal healthcheck failed")) + Expect(err.Error()).To(ContainSubstring("OMS-Portal returned status 404")) + Expect(err.Error()).To(ContainSubstring("owner must be a valid email address")) Expect(resp).To(BeNil()) }) }) diff --git a/internal/prompt/mocks.go b/internal/prompt/mocks.go new file mode 100644 index 000000000..1b5922a0e --- /dev/null +++ b/internal/prompt/mocks.go @@ -0,0 +1,329 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package prompt + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockPrompter creates a new instance of MockPrompter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockPrompter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockPrompter { + mock := &MockPrompter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockPrompter is an autogenerated mock type for the Prompter type +type MockPrompter struct { + mock.Mock +} + +type MockPrompter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockPrompter) EXPECT() *MockPrompter_Expecter { + return &MockPrompter_Expecter{mock: &_m.Mock} +} + +// Bool provides a mock function for the type MockPrompter +func (_mock *MockPrompter) Bool(prompt string, defaultValue bool) bool { + ret := _mock.Called(prompt, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for Bool") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func(string, bool) bool); ok { + r0 = returnFunc(prompt, defaultValue) + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockPrompter_Bool_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Bool' +type MockPrompter_Bool_Call struct { + *mock.Call +} + +// Bool is a helper method to define mock.On call +// - prompt string +// - defaultValue bool +func (_e *MockPrompter_Expecter) Bool(prompt any, defaultValue any) *MockPrompter_Bool_Call { + return &MockPrompter_Bool_Call{Call: _e.mock.On("Bool", prompt, defaultValue)} +} + +func (_c *MockPrompter_Bool_Call) Run(run func(prompt string, defaultValue bool)) *MockPrompter_Bool_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 bool + if args[1] != nil { + arg1 = args[1].(bool) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPrompter_Bool_Call) Return(b bool) *MockPrompter_Bool_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockPrompter_Bool_Call) RunAndReturn(run func(prompt string, defaultValue bool) bool) *MockPrompter_Bool_Call { + _c.Call.Return(run) + return _c +} + +// Choice provides a mock function for the type MockPrompter +func (_mock *MockPrompter) Choice(prompt string, choices []string, defaultValue string) string { + ret := _mock.Called(prompt, choices, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for Choice") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func(string, []string, string) string); ok { + r0 = returnFunc(prompt, choices, defaultValue) + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockPrompter_Choice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Choice' +type MockPrompter_Choice_Call struct { + *mock.Call +} + +// Choice is a helper method to define mock.On call +// - prompt string +// - choices []string +// - defaultValue string +func (_e *MockPrompter_Expecter) Choice(prompt any, choices any, defaultValue any) *MockPrompter_Choice_Call { + return &MockPrompter_Choice_Call{Call: _e.mock.On("Choice", prompt, choices, defaultValue)} +} + +func (_c *MockPrompter_Choice_Call) Run(run func(prompt string, choices []string, defaultValue string)) *MockPrompter_Choice_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 []string + if args[1] != nil { + arg1 = args[1].([]string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockPrompter_Choice_Call) Return(s string) *MockPrompter_Choice_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockPrompter_Choice_Call) RunAndReturn(run func(prompt string, choices []string, defaultValue string) string) *MockPrompter_Choice_Call { + _c.Call.Return(run) + return _c +} + +// Int provides a mock function for the type MockPrompter +func (_mock *MockPrompter) Int(prompt string, defaultValue int) int { + ret := _mock.Called(prompt, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for Int") + } + + var r0 int + if returnFunc, ok := ret.Get(0).(func(string, int) int); ok { + r0 = returnFunc(prompt, defaultValue) + } else { + r0 = ret.Get(0).(int) + } + return r0 +} + +// MockPrompter_Int_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Int' +type MockPrompter_Int_Call struct { + *mock.Call +} + +// Int is a helper method to define mock.On call +// - prompt string +// - defaultValue int +func (_e *MockPrompter_Expecter) Int(prompt any, defaultValue any) *MockPrompter_Int_Call { + return &MockPrompter_Int_Call{Call: _e.mock.On("Int", prompt, defaultValue)} +} + +func (_c *MockPrompter_Int_Call) Run(run func(prompt string, defaultValue int)) *MockPrompter_Int_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPrompter_Int_Call) Return(n int) *MockPrompter_Int_Call { + _c.Call.Return(n) + return _c +} + +func (_c *MockPrompter_Int_Call) RunAndReturn(run func(prompt string, defaultValue int) int) *MockPrompter_Int_Call { + _c.Call.Return(run) + return _c +} + +// String provides a mock function for the type MockPrompter +func (_mock *MockPrompter) String(prompt string, defaultValue string) string { + ret := _mock.Called(prompt, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for String") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { + r0 = returnFunc(prompt, defaultValue) + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockPrompter_String_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'String' +type MockPrompter_String_Call struct { + *mock.Call +} + +// String is a helper method to define mock.On call +// - prompt string +// - defaultValue string +func (_e *MockPrompter_Expecter) String(prompt any, defaultValue any) *MockPrompter_String_Call { + return &MockPrompter_String_Call{Call: _e.mock.On("String", prompt, defaultValue)} +} + +func (_c *MockPrompter_String_Call) Run(run func(prompt string, defaultValue string)) *MockPrompter_String_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPrompter_String_Call) Return(s string) *MockPrompter_String_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockPrompter_String_Call) RunAndReturn(run func(prompt string, defaultValue string) string) *MockPrompter_String_Call { + _c.Call.Return(run) + return _c +} + +// StringSlice provides a mock function for the type MockPrompter +func (_mock *MockPrompter) StringSlice(prompt string, defaultValue []string) []string { + ret := _mock.Called(prompt, defaultValue) + + if len(ret) == 0 { + panic("no return value specified for StringSlice") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func(string, []string) []string); ok { + r0 = returnFunc(prompt, defaultValue) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// MockPrompter_StringSlice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StringSlice' +type MockPrompter_StringSlice_Call struct { + *mock.Call +} + +// StringSlice is a helper method to define mock.On call +// - prompt string +// - defaultValue []string +func (_e *MockPrompter_Expecter) StringSlice(prompt any, defaultValue any) *MockPrompter_StringSlice_Call { + return &MockPrompter_StringSlice_Call{Call: _e.mock.On("StringSlice", prompt, defaultValue)} +} + +func (_c *MockPrompter_StringSlice_Call) Run(run func(prompt string, defaultValue []string)) *MockPrompter_StringSlice_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 []string + if args[1] != nil { + arg1 = args[1].([]string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockPrompter_StringSlice_Call) Return(strings []string) *MockPrompter_StringSlice_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *MockPrompter_StringSlice_Call) RunAndReturn(run func(prompt string, defaultValue []string) []string) *MockPrompter_StringSlice_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/installer/prompt.go b/internal/prompt/prompt.go similarity index 55% rename from internal/installer/prompt.go rename to internal/prompt/prompt.go index 31d360d18..0538e6c07 100644 --- a/internal/installer/prompt.go +++ b/internal/prompt/prompt.go @@ -1,7 +1,10 @@ // Copyright (c) Codesphere Inc. // SPDX-License-Identifier: Apache-2.0 -package installer +// Package prompt asks the operator questions on stdin. A prompter can be non-interactive, +// in which case every question is answered with its default instead of being asked, which is +// what unattended runs (CI, --yes style flags) use. +package prompt import ( "bufio" @@ -11,19 +14,36 @@ import ( "strings" ) -type Prompter struct { +// Prompter asks the operator a question and returns their answer, falling back to the +// default whenever there is none: an empty line, a closed stdin, or a prompter that was +// created non-interactive. +// +//mockery:generate: true +type Prompter interface { + String(prompt, defaultValue string) string + Int(prompt string, defaultValue int) int + StringSlice(prompt string, defaultValue []string) []string + Bool(prompt string, defaultValue bool) bool + Choice(prompt string, choices []string, defaultValue string) string +} + +// StdinPrompter is the Prompter that asks on stdin. +type StdinPrompter struct { reader *bufio.Reader interactive bool } -func NewPrompter(interactive bool) *Prompter { - return &Prompter{ +// NewPrompter returns a prompter reading from stdin. A non-interactive one never asks +// and answers every question with its default. +func NewPrompter(interactive bool) *StdinPrompter { + return &StdinPrompter{ reader: bufio.NewReader(os.Stdin), interactive: interactive, } } -func (p *Prompter) String(prompt, defaultValue string) string { +// String asks for a line of text. +func (p *StdinPrompter) String(prompt, defaultValue string) string { if !p.interactive { return defaultValue } @@ -43,7 +63,8 @@ func (p *Prompter) String(prompt, defaultValue string) string { return input } -func (p *Prompter) Int(prompt string, defaultValue int) int { +// Int asks for a number, falling back to the default when the answer is not one. +func (p *StdinPrompter) Int(prompt string, defaultValue int) int { if !p.interactive { return defaultValue } @@ -65,7 +86,8 @@ func (p *Prompter) Int(prompt string, defaultValue int) int { return value } -func (p *Prompter) StringSlice(prompt string, defaultValue []string) []string { +// StringSlice asks for a comma-separated list. +func (p *StdinPrompter) StringSlice(prompt string, defaultValue []string) []string { if !p.interactive { return defaultValue } @@ -99,7 +121,9 @@ func (p *Prompter) StringSlice(prompt string, defaultValue []string) []string { return result } -func (p *Prompter) Bool(prompt string, defaultValue bool) bool { +// Bool asks a yes/no question. Only "y" and "yes" are a yes, only "n" and "no" a no; +// anything else falls back to the default. +func (p *StdinPrompter) Bool(prompt string, defaultValue bool) bool { if !p.interactive { return defaultValue } @@ -120,7 +144,9 @@ func (p *Prompter) Bool(prompt string, defaultValue bool) bool { return input == "y" || input == "yes" } -func (p *Prompter) Choice(prompt string, choices []string, defaultValue string) string { +// Choice asks for one of the given options, falling back to the default when the answer +// is not among them. +func (p *StdinPrompter) Choice(prompt string, choices []string, defaultValue string) string { if !p.interactive { return defaultValue } diff --git a/internal/prompt/prompt_suite_test.go b/internal/prompt/prompt_suite_test.go new file mode 100644 index 000000000..fdaf9d97b --- /dev/null +++ b/internal/prompt/prompt_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package prompt + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPrompt(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Prompt Suite") +} diff --git a/internal/installer/prompt_test.go b/internal/prompt/prompt_test.go similarity index 94% rename from internal/installer/prompt_test.go rename to internal/prompt/prompt_test.go index 6b995cbf5..8caa79ef6 100644 --- a/internal/installer/prompt_test.go +++ b/internal/prompt/prompt_test.go @@ -1,7 +1,7 @@ // Copyright (c) Codesphere Inc. // SPDX-License-Identifier: Apache-2.0 -package installer +package prompt import ( "bufio" @@ -46,7 +46,7 @@ var _ = Describe("Prompter", func() { Context("interactive mode", func() { It("returns user input when provided", func() { input := "user-value\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -56,7 +56,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -66,7 +66,7 @@ var _ = Describe("Prompter", func() { It("trims whitespace from input", func() { input := " value with spaces \n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -88,7 +88,7 @@ var _ = Describe("Prompter", func() { Context("interactive mode", func() { It("returns parsed integer when valid input provided", func() { input := "123\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -98,7 +98,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -108,7 +108,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is invalid", func() { input := "not-a-number\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -118,7 +118,7 @@ var _ = Describe("Prompter", func() { It("handles negative numbers", func() { input := "-100\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -147,7 +147,7 @@ var _ = Describe("Prompter", func() { Context("interactive mode", func() { It("parses comma-separated values", func() { input := "one, two, three\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -158,7 +158,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" defaultVal := []string{"default1", "default2"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -168,7 +168,7 @@ var _ = Describe("Prompter", func() { It("trims whitespace from each value", func() { input := " one , two , three \n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -178,7 +178,7 @@ var _ = Describe("Prompter", func() { It("handles single value", func() { input := "single\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -188,7 +188,7 @@ var _ = Describe("Prompter", func() { It("filters out empty values", func() { input := "one, , two, , three\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -216,7 +216,7 @@ var _ = Describe("Prompter", func() { Context("interactive mode", func() { DescribeTable("boolean parsing", func(input string, expected bool) { - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input + "\n")), interactive: true, } @@ -236,7 +236,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -260,7 +260,7 @@ var _ = Describe("Prompter", func() { It("returns matching choice case-insensitively", func() { input := "OPTION2\n" choices := []string{"option1", "option2", "option3"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -271,7 +271,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is empty", func() { input := "\n" choices := []string{"option1", "option2", "option3"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -282,7 +282,7 @@ var _ = Describe("Prompter", func() { It("returns default when input is invalid", func() { input := "invalid-option\n" choices := []string{"option1", "option2", "option3"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } @@ -293,7 +293,7 @@ var _ = Describe("Prompter", func() { It("handles exact match", func() { input := "option2\n" choices := []string{"option1", "option2", "option3"} - p := &Prompter{ + p := &StdinPrompter{ reader: bufio.NewReader(strings.NewReader(input)), interactive: true, } diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index e95f889cf..963b1e832 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -11,9 +11,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry ---------- Module: cloud.google.com/go/auth -Version: v0.23.0 +Version: v0.23.2 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/auth/v0.23.0/auth/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/auth/v0.23.2/auth/LICENSE ---------- Module: cloud.google.com/go/auth/oauth2adapt @@ -23,9 +23,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/auth/oauth2adapt ---------- Module: cloud.google.com/go/compute -Version: v1.66.0 +Version: v1.68.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/compute/v1.66.0/compute/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/compute/v1.68.0/compute/LICENSE ---------- Module: cloud.google.com/go/compute/metadata @@ -71,9 +71,9 @@ License URL: https://github.com/imdario/mergo/blob/v1.0.2/LICENSE ---------- Module: filippo.io/age -Version: v1.3.1 +Version: v1.3.2 License: BSD-3-Clause -License URL: https://github.com/FiloSottile/age/blob/v1.3.1/LICENSE +License URL: https://github.com/FiloSottile/age/blob/v1.3.2/LICENSE ---------- Module: filippo.io/edwards25519 @@ -167,9 +167,9 @@ License URL: https://github.com/argoproj/argo-cd/blob/7660efb23b2d/gitops-engine ---------- Module: github.com/argoproj/argo-cd/v3 -Version: v3.5.0 +Version: v3.5.3 License: Apache-2.0 -License URL: https://github.com/argoproj/argo-cd/blob/v3.5.0/LICENSE +License URL: https://github.com/argoproj/argo-cd/blob/v3.5.3/LICENSE ---------- Module: github.com/argoproj/pkg/v2 @@ -299,9 +299,9 @@ License URL: https://github.com/cloudnative-pg/machinery/blob/v0.5.0/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go -Version: v1.20.0 +Version: v1.40.0 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v1.20.0/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v1.40.0/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl @@ -329,15 +329,15 @@ License URL: https://github.com/creativeprojects/go-selfupdate/blob/v1.6.0/LICEN ---------- Module: github.com/cyphar/filepath-securejoin -Version: v0.6.1 +Version: v0.7.0 License: MPL-2.0 -License URL: https://github.com/cyphar/filepath-securejoin/blob/v0.6.1/COPYING.md +License URL: https://github.com/cyphar/filepath-securejoin/blob/v0.7.0/COPYING.md ---------- Module: github.com/cyphar/filepath-securejoin -Version: v0.6.1 +Version: v0.7.0 License: BSD-3-Clause -License URL: https://github.com/cyphar/filepath-securejoin/blob/v0.6.1/COPYING.md +License URL: https://github.com/cyphar/filepath-securejoin/blob/v0.7.0/COPYING.md ---------- Module: github.com/davecgh/go-spew/spew @@ -357,6 +357,18 @@ Version: v1.12.0 License: MIT License URL: https://github.com/dlclark/regexp2/blob/v1.12.0/LICENSE +---------- +Module: github.com/docker/cli/cli/config +Version: v29.7.2 +License: Apache-2.0 +License URL: https://github.com/docker/cli/blob/v29.7.2/LICENSE + +---------- +Module: github.com/docker/docker-credential-helpers +Version: v0.9.8 +License: MIT +License URL: https://github.com/docker/docker-credential-helpers/blob/v0.9.8/LICENSE + ---------- Module: github.com/dylibso/observe-sdk/go Version: v0.0.0-20240828172851-9145d8ad07e1 @@ -419,9 +431,9 @@ License URL: https://github.com/felixge/httpsnoop/blob/v1.1.0/LICENSE.txt ---------- Module: github.com/fluxcd/cli-utils/pkg -Version: v1.2.1 +Version: v1.2.2 License: Apache-2.0 -License URL: https://github.com/fluxcd/cli-utils/blob/v1.2.1/LICENSE +License URL: https://github.com/fluxcd/cli-utils/blob/v1.2.2/LICENSE ---------- Module: github.com/fsnotify/fsnotify @@ -515,87 +527,87 @@ License URL: https://github.com/go-logr/zapr/blob/v1.3.0/LICENSE ---------- Module: github.com/go-openapi/jsonpointer -Version: v0.23.1 +Version: v1.0.0 License: Apache-2.0 -License URL: https://github.com/go-openapi/jsonpointer/blob/v0.23.1/LICENSE +License URL: https://github.com/go-openapi/jsonpointer/blob/v1.0.0/LICENSE ---------- Module: github.com/go-openapi/jsonreference -Version: v0.21.6 +Version: v1.0.0 License: Apache-2.0 -License URL: https://github.com/go-openapi/jsonreference/blob/v0.21.6/LICENSE +License URL: https://github.com/go-openapi/jsonreference/blob/v1.0.0/LICENSE ---------- Module: github.com/go-openapi/swag -Version: v0.26.1 +Version: v0.27.1 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/v0.26.1/LICENSE +License URL: https://github.com/go-openapi/swag/blob/v0.27.1/LICENSE ---------- Module: github.com/go-openapi/swag/cmdutils -Version: v0.26.1 +Version: v0.27.1 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/cmdutils/v0.26.1/cmdutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/cmdutils/v0.27.1/cmdutils/LICENSE ---------- Module: github.com/go-openapi/swag/conv -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/conv/v0.26.1/conv/LICENSE +License URL: https://github.com/go-openapi/swag/blob/conv/v0.27.3/conv/LICENSE ---------- Module: github.com/go-openapi/swag/fileutils -Version: v0.26.1 -License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/fileutils/v0.26.1/fileutils/LICENSE - ----------- -Module: github.com/go-openapi/swag/jsonname -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/jsonname/v0.26.1/jsonname/LICENSE +License URL: https://github.com/go-openapi/swag/blob/fileutils/v0.27.3/fileutils/LICENSE ---------- Module: github.com/go-openapi/swag/jsonutils -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/jsonutils/v0.26.1/jsonutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/jsonutils/v0.27.3/jsonutils/LICENSE ---------- Module: github.com/go-openapi/swag/loading -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/loading/v0.26.1/loading/LICENSE +License URL: https://github.com/go-openapi/swag/blob/loading/v0.27.3/loading/LICENSE ---------- Module: github.com/go-openapi/swag/mangling -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/mangling/v0.26.1/mangling/LICENSE +License URL: https://github.com/go-openapi/swag/blob/mangling/v0.27.3/mangling/LICENSE ---------- Module: github.com/go-openapi/swag/netutils -Version: v0.26.1 +Version: v0.27.1 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/netutils/v0.26.1/netutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/netutils/v0.27.1/netutils/LICENSE + +---------- +Module: github.com/go-openapi/swag/pools +Version: v0.27.3 +License: Apache-2.0 +License URL: https://github.com/go-openapi/swag/blob/pools/v0.27.3/pools/LICENSE ---------- Module: github.com/go-openapi/swag/stringutils -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/stringutils/v0.26.1/stringutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/stringutils/v0.27.3/stringutils/LICENSE ---------- Module: github.com/go-openapi/swag/typeutils -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/typeutils/v0.26.1/typeutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/typeutils/v0.27.3/typeutils/LICENSE ---------- Module: github.com/go-openapi/swag/yamlutils -Version: v0.26.1 +Version: v0.27.3 License: Apache-2.0 -License URL: https://github.com/go-openapi/swag/blob/yamlutils/v0.26.1/yamlutils/LICENSE +License URL: https://github.com/go-openapi/swag/blob/yamlutils/v0.27.3/yamlutils/LICENSE ---------- Module: github.com/go-redis/cache/v9 @@ -651,18 +663,18 @@ Version: v0.7.0 License: BSD-3-Clause License URL: https://github.com/google/go-cmp/blob/v0.7.0/LICENSE +---------- +Module: github.com/google/go-containerregistry +Version: v0.22.1 +License: Apache-2.0 +License URL: https://github.com/google/go-containerregistry/blob/v0.22.1/LICENSE + ---------- Module: github.com/google/go-github/v69/github Version: v69.2.0 License: BSD-3-Clause License URL: https://github.com/google/go-github/blob/v69.2.0/LICENSE ----------- -Module: github.com/google/go-github/v74/github -Version: v74.0.0 -License: BSD-3-Clause -License URL: https://github.com/google/go-github/blob/v74.0.0/LICENSE - ---------- Module: github.com/google/go-github/v86/github Version: v86.0.0 @@ -707,9 +719,9 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. ---------- Module: github.com/googleapis/gax-go/v2 -Version: v2.23.0 +Version: v2.24.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.23.0/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.24.0/v2/LICENSE ---------- Module: github.com/gorilla/websocket @@ -868,28 +880,40 @@ License: MIT License URL: https://github.com/kevinburke/ssh_config/blob/v1.6.0/LICENSE ---------- -Module: github.com/klauspost/compress/internal -Version: v1.19.1 +Module: github.com/klauspost/compress +Version: v1.19.2 License: MIT -License URL: https://github.com/klauspost/compress/blob/v1.19.1/LICENSE +License URL: https://github.com/klauspost/compress/blob/v1.19.2/LICENSE ---------- -Module: github.com/klauspost/compress/internal -Version: v1.19.1 +Module: github.com/klauspost/compress +Version: v1.19.2 License: Apache-2.0 -License URL: https://github.com/klauspost/compress/blob/v1.19.1/LICENSE +License URL: https://github.com/klauspost/compress/blob/v1.19.2/LICENSE + +---------- +Module: github.com/klauspost/compress +Version: v1.19.2 +License: BSD-3-Clause +License URL: https://github.com/klauspost/compress/blob/v1.19.2/LICENSE ---------- -Module: github.com/klauspost/compress/internal -Version: v1.19.1 +Module: github.com/klauspost/compress/internal/snapref +Version: v1.19.2 License: BSD-3-Clause -License URL: https://github.com/klauspost/compress/blob/v1.19.1/LICENSE +License URL: https://github.com/klauspost/compress/blob/v1.19.2/internal/snapref/LICENSE ---------- Module: github.com/klauspost/compress/s2 -Version: v1.19.1 +Version: v1.19.2 License: BSD-3-Clause -License URL: https://github.com/klauspost/compress/blob/v1.19.1/s2/LICENSE +License URL: https://github.com/klauspost/compress/blob/v1.19.2/s2/LICENSE + +---------- +Module: github.com/klauspost/compress/zstd/internal/xxhash +Version: v1.19.2 +License: MIT +License URL: https://github.com/klauspost/compress/blob/v1.19.2/zstd/internal/xxhash/LICENSE.txt ---------- Module: github.com/klauspost/cpuid/v2 @@ -905,9 +929,9 @@ License URL: https://github.com/kr/fs/blob/v0.1.0/LICENSE ---------- Module: github.com/kube-object-storage/lib-bucket-provisioner/pkg/apis/objectbucket.io -Version: v0.0.0-20221122204822-d1a8c34382f1 +Version: v0.0.0-20260420161730-5164e3746489 License: Apache-2.0 -License URL: https://github.com/kube-object-storage/lib-bucket-provisioner/blob/d1a8c34382f1/LICENSE +License URL: https://github.com/kube-object-storage/lib-bucket-provisioner/blob/5164e3746489/LICENSE ---------- Module: github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1 @@ -1043,9 +1067,9 @@ License URL: https://github.com/munnerz/goautoneg/blob/a7dc8b61c822/LICENSE ---------- Module: github.com/onsi/gomega -Version: v1.42.1 +Version: v1.43.0 License: MIT -License URL: https://github.com/onsi/gomega/blob/v1.42.1/LICENSE +License URL: https://github.com/onsi/gomega/blob/v1.43.0/LICENSE ---------- Module: github.com/opencontainers/go-digest @@ -1157,9 +1181,9 @@ License URL: https://github.com/robfig/cron/blob/bc59245fe10e/LICENSE ---------- Module: github.com/rook/rook/pkg/apis/ceph.rook.io -Version: v0.0.0-20260811210519-a4c28bc84816 +Version: v0.0.0-20260915091545-fb2d518f653f License: Apache-2.0 -License URL: https://github.com/rook/rook/blob/a4c28bc84816/pkg/apis/LICENSE +License URL: https://github.com/rook/rook/blob/fb2d518f653f/pkg/apis/LICENSE ---------- Module: github.com/rubenv/sql-migrate @@ -1187,9 +1211,9 @@ License URL: https://github.com/ryanuber/go-glob/blob/v1.0.0/LICENSE ---------- Module: github.com/santhosh-tekuri/jsonschema/v6 -Version: v6.0.2 +Version: v6.0.3 License: Apache-2.0 -License URL: https://github.com/santhosh-tekuri/jsonschema/blob/v6.0.2/LICENSE +License URL: https://github.com/santhosh-tekuri/jsonschema/blob/v6.0.3/LICENSE ---------- Module: github.com/sergi/go-diff/diffmatchpatch @@ -1205,9 +1229,9 @@ License URL: https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE ---------- Module: github.com/sirupsen/logrus -Version: v1.9.4 +Version: v1.10.1 License: MIT -License URL: https://github.com/sirupsen/logrus/blob/v1.9.4/LICENSE +License URL: https://github.com/sirupsen/logrus/blob/v1.10.1/LICENSE ---------- Module: github.com/skeema/knownhosts @@ -1241,9 +1265,21 @@ License URL: https://github.com/stretchr/objx/blob/v0.5.3/LICENSE ---------- Module: github.com/stretchr/testify -Version: v1.11.1 +Version: v1.12.1 License: MIT -License URL: https://github.com/stretchr/testify/blob/v1.11.1/LICENSE +License URL: https://github.com/stretchr/testify/blob/v1.12.1/LICENSE + +---------- +Module: github.com/stretchr/testify/internal/difflib +Version: v1.12.1 +License: BSD-3-Clause +License URL: https://github.com/stretchr/testify/blob/v1.12.1/internal/difflib/LICENSE + +---------- +Module: github.com/stretchr/testify/internal/spew +Version: v1.12.1 +License: ISC +License URL: https://github.com/stretchr/testify/blob/v1.12.1/internal/spew/LICENSE ---------- Module: github.com/tetratelabs/wabin @@ -1415,51 +1451,51 @@ License URL: https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE ---------- Module: golang.org/x/crypto -Version: v0.55.0 +Version: v0.57.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/crypto/+/v0.55.0:LICENSE +License URL: https://cs.opensource.google/go/x/crypto/+/v0.57.0:LICENSE ---------- Module: golang.org/x/mod/semver -Version: v0.39.0 +Version: v0.41.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/mod/+/v0.39.0:LICENSE +License URL: https://cs.opensource.google/go/x/mod/+/v0.41.0:LICENSE ---------- Module: golang.org/x/net -Version: v0.57.0 +Version: v0.58.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/net/+/v0.57.0:LICENSE +License URL: https://cs.opensource.google/go/x/net/+/v0.58.0:LICENSE ---------- Module: golang.org/x/oauth2 -Version: v0.36.0 +Version: v0.37.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE +License URL: https://cs.opensource.google/go/x/oauth2/+/v0.37.0:LICENSE ---------- Module: golang.org/x/sync -Version: v0.22.0 +Version: v0.23.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sync/+/v0.22.0:LICENSE +License URL: https://cs.opensource.google/go/x/sync/+/v0.23.0:LICENSE ---------- Module: golang.org/x/sys -Version: v0.47.0 +Version: v0.48.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sys/+/v0.47.0:LICENSE +License URL: https://cs.opensource.google/go/x/sys/+/v0.48.0:LICENSE ---------- Module: golang.org/x/term -Version: v0.45.0 +Version: v0.46.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/term/+/v0.45.0:LICENSE +License URL: https://cs.opensource.google/go/x/term/+/v0.46.0:LICENSE ---------- Module: golang.org/x/text -Version: v0.41.0 +Version: v0.42.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/text/+/v0.41.0:LICENSE +License URL: https://cs.opensource.google/go/x/text/+/v0.42.0:LICENSE ---------- Module: golang.org/x/time/rate @@ -1475,15 +1511,15 @@ License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api -Version: v0.293.0 +Version: v0.298.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.293.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.298.0/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.293.0 +Version: v0.298.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.293.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.298.0/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis @@ -1499,15 +1535,15 @@ License URL: https://github.com/googleapis/go-genproto/blob/e059f2f05d78/googlea ---------- Module: google.golang.org/genproto/googleapis/rpc -Version: v0.0.0-20260807164820-c8921c73eeea +Version: v0.0.0-20260819154853-08b0e4226688 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/c8921c73eeea/googleapis/rpc/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/08b0e4226688/googleapis/rpc/LICENSE ---------- Module: google.golang.org/grpc -Version: v1.83.0 +Version: v1.83.2 License: Apache-2.0 -License URL: https://github.com/grpc/grpc-go/blob/v1.83.0/LICENSE +License URL: https://github.com/grpc/grpc-go/blob/v1.83.2/LICENSE ---------- Module: google.golang.org/protobuf @@ -1553,81 +1589,81 @@ License URL: https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE ---------- Module: helm.sh/helm/v4 -Version: v4.2.3 +Version: v4.3.0 License: Apache-2.0 -License URL: https://github.com/helm/helm/blob/v4.2.3/LICENSE +License URL: https://github.com/helm/helm/blob/v4.3.0/LICENSE ---------- Module: k8s.io/api -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/api/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/api/blob/v0.36.4/LICENSE ---------- Module: k8s.io/apiextensions-apiserver/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/apiextensions-apiserver/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/apiextensions-apiserver/blob/v0.36.4/LICENSE ---------- Module: k8s.io/apimachinery/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/apimachinery/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/apimachinery/blob/v0.36.4/LICENSE ---------- Module: k8s.io/apimachinery/third_party/forked/golang -Version: v0.36.3 +Version: v0.36.4 License: BSD-3-Clause -License URL: https://github.com/kubernetes/apimachinery/blob/v0.36.3/third_party/forked/golang/LICENSE +License URL: https://github.com/kubernetes/apimachinery/blob/v0.36.4/third_party/forked/golang/LICENSE ---------- Module: k8s.io/apiserver/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/apiserver/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/apiserver/blob/v0.36.4/LICENSE ---------- Module: k8s.io/cli-runtime/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/cli-runtime/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/cli-runtime/blob/v0.36.4/LICENSE ---------- Module: k8s.io/client-go -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/client-go/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.36.4/LICENSE ---------- Module: k8s.io/client-go/third_party/forked/golang/template -Version: v0.36.3 +Version: v0.36.4 License: BSD-3-Clause -License URL: https://github.com/kubernetes/client-go/blob/v0.36.3/third_party/forked/golang/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.36.4/third_party/forked/golang/LICENSE ---------- Module: k8s.io/client-go/third_party/forked/httpcache -Version: v0.36.3 +Version: v0.36.4 License: MIT -License URL: https://github.com/kubernetes/client-go/blob/v0.36.3/third_party/forked/httpcache/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.36.4/third_party/forked/httpcache/LICENSE ---------- Module: k8s.io/component-base -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/component-base/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/component-base/blob/v0.36.4/LICENSE ---------- Module: k8s.io/component-helpers -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/component-helpers/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/component-helpers/blob/v0.36.4/LICENSE ---------- Module: k8s.io/controller-manager/pkg/features -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/controller-manager/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/controller-manager/blob/v0.36.4/LICENSE ---------- Module: k8s.io/klog/v2 @@ -1637,45 +1673,45 @@ License URL: https://github.com/kubernetes/klog/blob/v2.140.0/LICENSE ---------- Module: k8s.io/kube-aggregator/pkg/apis/apiregistration -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/kube-aggregator/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/kube-aggregator/blob/v0.36.4/LICENSE ---------- Module: k8s.io/kube-openapi/pkg -Version: v0.0.0-20260603220949-865597e52e25 +Version: v0.0.0-20260721132016-d427ff9ee9ad License: Apache-2.0 -License URL: https://github.com/kubernetes/kube-openapi/blob/865597e52e25/LICENSE +License URL: https://github.com/kubernetes/kube-openapi/blob/d427ff9ee9ad/LICENSE ---------- Module: k8s.io/kube-openapi/pkg/internal/third_party/go-json-experiment/json -Version: v0.0.0-20260603220949-865597e52e25 +Version: v0.0.0-20260721132016-d427ff9ee9ad License: BSD-3-Clause -License URL: https://github.com/kubernetes/kube-openapi/blob/865597e52e25/pkg/internal/third_party/go-json-experiment/json/LICENSE +License URL: https://github.com/kubernetes/kube-openapi/blob/d427ff9ee9ad/pkg/internal/third_party/go-json-experiment/json/LICENSE ---------- Module: k8s.io/kube-openapi/pkg/validation/spec -Version: v0.0.0-20260603220949-865597e52e25 +Version: v0.0.0-20260721132016-d427ff9ee9ad License: Apache-2.0 -License URL: https://github.com/kubernetes/kube-openapi/blob/865597e52e25/pkg/validation/spec/LICENSE +License URL: https://github.com/kubernetes/kube-openapi/blob/d427ff9ee9ad/pkg/validation/spec/LICENSE ---------- Module: k8s.io/kubectl/pkg -Version: v0.36.3 +Version: v0.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/kubectl/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/kubectl/blob/v0.36.4/LICENSE ---------- Module: k8s.io/kubernetes/pkg -Version: v1.36.3 +Version: v1.36.4 License: Apache-2.0 -License URL: https://github.com/kubernetes/kubernetes/blob/v1.36.3/LICENSE +License URL: https://github.com/kubernetes/kubernetes/blob/v1.36.4/LICENSE ---------- Module: k8s.io/streaming/pkg -Version: v0.36.3 +Version: v0.37.0 License: Apache-2.0 -License URL: https://github.com/kubernetes/streaming/blob/v0.36.3/LICENSE +License URL: https://github.com/kubernetes/streaming/blob/v0.37.0/LICENSE ---------- Module: k8s.io/utils @@ -1739,9 +1775,9 @@ License URL: https://github.com/kubernetes-sigs/randfill/blob/v1.0.0/LICENSE ---------- Module: sigs.k8s.io/structured-merge-diff/v6 -Version: v6.4.0 +Version: v6.4.2 License: Apache-2.0 -License URL: https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.4.0/LICENSE +License URL: https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.4.2/LICENSE ---------- Module: sigs.k8s.io/yaml diff --git a/internal/util/email.go b/internal/util/email.go new file mode 100644 index 000000000..3ec34317d --- /dev/null +++ b/internal/util/email.go @@ -0,0 +1,18 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "fmt" + "net/mail" +) + +// ValidateEmail checks that the given string is a syntactically valid email address. +func ValidateEmail(email string) error { + if _, err := mail.ParseAddress(email); err != nil { + return fmt.Errorf("expected a valid email address, got %q", email) + } + + return nil +} diff --git a/internal/util/email_test.go b/internal/util/email_test.go new file mode 100644 index 000000000..a95693b95 --- /dev/null +++ b/internal/util/email_test.go @@ -0,0 +1,30 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("ValidateEmail", func() { + It("accepts a valid email address", func() { + err := util.ValidateEmail("jane.doe@example.com") + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns error for a plain string", func() { + err := util.ValidateEmail("test") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("expected a valid email address")) + }) + + It("returns error for an empty string", func() { + err := util.ValidateEmail("") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("expected a valid email address")) + }) +}) diff --git a/internal/util/mocks.go b/internal/util/mocks.go index f307cc039..682b2f409 100644 --- a/internal/util/mocks.go +++ b/internal/util/mocks.go @@ -703,8 +703,8 @@ func (_c *MockFileIO_ReadDir_Call) Run(run func(dirname string)) *MockFileIO_Rea return _c } -func (_c *MockFileIO_ReadDir_Call) Return(vs []os.DirEntry, err error) *MockFileIO_ReadDir_Call { - _c.Call.Return(vs, err) +func (_c *MockFileIO_ReadDir_Call) Return(dirEntrys []os.DirEntry, err error) *MockFileIO_ReadDir_Call { + _c.Call.Return(dirEntrys, err) return _c } diff --git a/internal/util/semver.go b/internal/util/semver.go new file mode 100644 index 000000000..2f04ce1ad --- /dev/null +++ b/internal/util/semver.go @@ -0,0 +1,27 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "strings" + + "golang.org/x/mod/semver" +) + +// InstallVersionAtLeast returns true if the codesphere installVersion is higher or equal to minimum. +func InstallVersionAtLeast(installVersion, minimum string) bool { + var v string + for _, prefix := range []string{"codesphere-"} { + v = strings.TrimPrefix(installVersion, prefix) + } + + parsedVersion := semver.Canonical(v) + if parsedVersion == "" { + return false + } + + compare := semver.Compare(parsedVersion, minimum) >= 0 + + return compare +} diff --git a/internal/util/semver_test.go b/internal/util/semver_test.go new file mode 100644 index 000000000..dbf64880d --- /dev/null +++ b/internal/util/semver_test.go @@ -0,0 +1,26 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import "testing" + +func TestInstallVersionAtLeast(t *testing.T) { + t.Run("rejects lower versions", func(t *testing.T) { + if InstallVersionAtLeast("codesphere-v1.105.0", "v1.106.0") { + t.Fatal("expected codesphere-v1.105.0 to be below v1.106.0") + } + }) + + t.Run("accepts exact version matches", func(t *testing.T) { + if !InstallVersionAtLeast("codesphere-v1.106.0", "v1.106.0") { + t.Fatal("expected codesphere-v1.106.0 to satisfy v1.106.0") + } + }) + + t.Run("rejects invalid versions", func(t *testing.T) { + if InstallVersionAtLeast("not-a-version", "v1.106.0") { + t.Fatal("expected invalid versions to be rejected") + } + }) +} diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 000000000..3b5c63cdb --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,2 @@ +### Important Context to the PR +- [ ] This change introduces a **breaking change** and for that I cleared it with relevant stakeholders upfront and added an **explanation** to the PR description. Breaking can e.g. mean that this new installer can not install older versions of Codesphere