From d1ed89bdfec81eccdd29e108016fd846c8f90828 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 3 Sep 2026 21:23:02 +0000 Subject: [PATCH 01/17] feat(secrets): support SOPS-encrypted secret sources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- THIRD_PARTY_LICENSES.md | 349 +++++++++------ cmd/ci/ci.go | 15 +- cmd/secrets/bind.go | 55 ++- cmd/secrets/secrets.go | 12 +- cmd/secrets/source.go | 223 +++++++++ cmd/workspace/up/project_secrets.go | 176 ++++++++ cmd/workspace/up/secrets_test.go | 127 ++++-- cmd/workspace/up/up_client.go | 305 +++++++------ cmd/workspace/up/up_flags.go | 15 +- e2e/tests/up/sops.go | 162 +++++++ .../docker-sops-project/.devcontainer.json | 4 + .../docker-sops-project/.devsy/config.yaml | 6 + .../docker-sops-project/secrets.enc.yaml | 17 + .../docker-sops-source/.devcontainer.json | 4 + .../docker-sops-source/secrets.enc.yaml | 17 + go.mod | 67 ++- go.sum | 221 ++++++--- pkg/git/inspect.go | 139 ++++++ pkg/git/repo_test.go | 20 +- pkg/secrets/local_source.go | 36 ++ pkg/secrets/project_config.go | 191 ++++++++ pkg/secrets/project_config_test.go | 56 +++ pkg/secrets/reference.go | 79 ++++ pkg/secrets/reference_test.go | 48 ++ pkg/secrets/resolver.go | 81 ++++ pkg/secrets/resolver_test.go | 74 +++ pkg/secrets/sops_source.go | 211 +++++++++ pkg/secrets/sops_source_test.go | 114 +++++ pkg/secrets/source.go | 17 + pkg/secrets/source_config.go | 148 ++++++ pkg/secrets/source_factory.go | 57 +++ pkg/secrets/testdata/sops-age.yaml | 17 + .../docs/developing-in-workspaces/secrets.mdx | 422 +++++++++++++----- 33 files changed, 2957 insertions(+), 528 deletions(-) create mode 100644 cmd/secrets/source.go create mode 100644 cmd/workspace/up/project_secrets.go create mode 100644 e2e/tests/up/sops.go create mode 100644 e2e/tests/up/testdata/docker-sops-project/.devcontainer.json create mode 100644 e2e/tests/up/testdata/docker-sops-project/.devsy/config.yaml create mode 100644 e2e/tests/up/testdata/docker-sops-project/secrets.enc.yaml create mode 100644 e2e/tests/up/testdata/docker-sops-source/.devcontainer.json create mode 100644 e2e/tests/up/testdata/docker-sops-source/secrets.enc.yaml create mode 100644 pkg/git/inspect.go create mode 100644 pkg/secrets/local_source.go create mode 100644 pkg/secrets/project_config.go create mode 100644 pkg/secrets/project_config_test.go create mode 100644 pkg/secrets/reference.go create mode 100644 pkg/secrets/reference_test.go create mode 100644 pkg/secrets/resolver.go create mode 100644 pkg/secrets/resolver_test.go create mode 100644 pkg/secrets/sops_source.go create mode 100644 pkg/secrets/sops_source_test.go create mode 100644 pkg/secrets/source.go create mode 100644 pkg/secrets/source_config.go create mode 100644 pkg/secrets/source_factory.go create mode 100644 pkg/secrets/testdata/sops-age.yaml diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 2e80c698f..20781ea40 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -14,50 +14,75 @@ To regenerate this file after changing dependencies, run `task cli:licenses`. | Dependency | Version | License | | ---------- | ------- | ------- | | [al.essio.dev/pkg/shellescape](https://al.essio.dev/pkg/shellescape) | `v1.6.0` | MIT | -| [cel.dev/expr](https://cel.dev/expr) | `v0.25.1` | Apache-2.0 | +| [cel.dev/expr](https://cel.dev/expr) | `v0.25.2` | Apache-2.0 | | [charm.land/bubbles/v2](https://charm.land/bubbles/v2) | `v2.0.0` | MIT | | [charm.land/bubbletea/v2](https://charm.land/bubbletea/v2) | `v2.0.2` | MIT | | [charm.land/huh/v2](https://charm.land/huh/v2) | `v2.0.3` | MIT | -| [charm.land/lipgloss/v2](https://charm.land/lipgloss/v2) | `v2.0.3` | MIT | +| [charm.land/lipgloss/v2](https://charm.land/lipgloss/v2) | `v2.0.5` | MIT | +| [cloud.google.com/go](https://cloud.google.com/go) | `v0.123.0` | Apache-2.0 | +| [cloud.google.com/go/auth](https://cloud.google.com/go/auth) | `v0.22.0` | Apache-2.0 | +| [cloud.google.com/go/auth/oauth2adapt](https://cloud.google.com/go/auth/oauth2adapt) | `v0.2.8` | Apache-2.0 | | [cloud.google.com/go/compute/metadata](https://cloud.google.com/go/compute/metadata) | `v0.9.0` | Apache-2.0 | -| [code.gitea.io/sdk/gitea](https://code.gitea.io/sdk/gitea) | `v0.22.1` | MIT | -| [filippo.io/edwards25519](https://filippo.io/edwards25519) | `v1.1.0` | BSD-3-Clause | -| [github.com/42wim/httpsig](https://github.com/42wim/httpsig) | `v1.2.3` | BSD-3-Clause | +| [cloud.google.com/go/iam](https://cloud.google.com/go/iam) | `v1.12.0` | Apache-2.0 | +| [cloud.google.com/go/kms](https://cloud.google.com/go/kms) | `v1.32.0` | Apache-2.0 | +| [cloud.google.com/go/longrunning](https://cloud.google.com/go/longrunning) | `v1.2.0` | Apache-2.0 | +| [cloud.google.com/go/monitoring](https://cloud.google.com/go/monitoring) | `v1.30.0` | Apache-2.0 | +| [cloud.google.com/go/storage](https://cloud.google.com/go/storage) | `v1.63.1` | Apache-2.0 | +| [code.gitea.io/sdk/gitea](https://code.gitea.io/sdk/gitea) | `v0.25.1` | MIT | +| [filippo.io/age](https://filippo.io/age) | `v1.3.1` | BSD-3-Clause | +| [filippo.io/edwards25519](https://filippo.io/edwards25519) | `v1.2.0` | BSD-3-Clause | +| [filippo.io/hpke](https://filippo.io/hpke) | `v0.4.0` | BSD-3-Clause | +| [github.com/42wim/httpsig](https://github.com/42wim/httpsig) | `v1.2.4` | BSD-3-Clause | | [github.com/AlecAivazis/survey/v2](https://github.com/AlecAivazis/survey) | `v2.3.7` | MIT | -| [github.com/Azure/azure-sdk-for-go/sdk/azcore](https://github.com/Azure/azure-sdk-for-go) | `v1.21.0` | MIT | -| [github.com/Azure/azure-sdk-for-go/sdk/azidentity](https://github.com/Azure/azure-sdk-for-go) | `v1.13.1` | MIT | -| [github.com/Azure/azure-sdk-for-go/sdk/internal](https://github.com/Azure/azure-sdk-for-go) | `v1.11.2` | MIT | +| [github.com/Azure/azure-sdk-for-go/sdk/azcore](https://github.com/Azure/azure-sdk-for-go) | `v1.22.0` | MIT | +| [github.com/Azure/azure-sdk-for-go/sdk/azidentity](https://github.com/Azure/azure-sdk-for-go) | `v1.14.0` | MIT | +| [github.com/Azure/azure-sdk-for-go/sdk/internal](https://github.com/Azure/azure-sdk-for-go) | `v1.12.0` | MIT | +| [github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys](https://github.com/Azure/azure-sdk-for-go) | `v1.5.0` | MIT | +| [github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal](https://github.com/Azure/azure-sdk-for-go) | `v1.2.0` | MIT | | [github.com/Azure/go-ansiterm](https://github.com/Azure/go-ansiterm) | `v0.0.0-20250102033503-faa5f7b0171c` | MIT | -| [github.com/AzureAD/microsoft-authentication-library-for-go](https://github.com/AzureAD/microsoft-authentication-library-for-go) | `v1.6.0` | MIT | -| [github.com/BurntSushi/toml](https://github.com/BurntSushi/toml) | `v1.5.0` | MIT | -| [github.com/Masterminds/semver/v3](https://github.com/Masterminds/semver) | `v3.4.0` | MIT | -| [github.com/Microsoft/go-winio](https://github.com/Microsoft/go-winio) | `v0.6.2` | MIT | +| [github.com/AzureAD/microsoft-authentication-library-for-go](https://github.com/AzureAD/microsoft-authentication-library-for-go) | `v1.7.2` | MIT | +| [github.com/BurntSushi/toml](https://github.com/BurntSushi/toml) | `v1.6.0` | MIT | +| [github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp](https://github.com/GoogleCloudPlatform/opentelemetry-operations-go) | `v1.34.0` | Apache-2.0 | +| [github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric](https://github.com/GoogleCloudPlatform/opentelemetry-operations-go) | `v0.58.0` | Apache-2.0 | +| [github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping](https://github.com/GoogleCloudPlatform/opentelemetry-operations-go) | `v0.58.0` | Apache-2.0 | +| [github.com/Masterminds/semver/v3](https://github.com/Masterminds/semver) | `v3.5.0` | MIT | +| [github.com/Microsoft/go-winio](https://github.com/Microsoft/go-winio) | `v0.6.3-0.20260107145400-75610162e7da` | MIT | | [github.com/NYTimes/gziphandler](https://github.com/NYTimes/gziphandler) | `v1.1.1` | Apache-2.0 | +| [github.com/ProtonMail/go-crypto](https://github.com/ProtonMail/go-crypto) | `v1.4.1` | BSD-3-Clause | | [github.com/agext/levenshtein](https://github.com/agext/levenshtein) | `v1.2.3` | Apache-2.0 | | [github.com/alexbrainman/sspi](https://github.com/alexbrainman/sspi) | `v0.0.0-20231016080023-1a75b4708caa` | BSD-3-Clause | +| [github.com/andybalholm/brotli](https://github.com/andybalholm/brotli) | `v1.1.1` | MIT | | [github.com/antlr4-go/antlr/v4](https://github.com/antlr4-go/antlr) | `v4.13.1` | BSD-3-Clause | | [github.com/atotto/clipboard](https://github.com/atotto/clipboard) | `v0.1.4` | BSD-3-Clause | -| [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | `v1.41.4` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `v1.32.12` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) | `v1.19.12` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://github.com/aws/aws-sdk-go-v2) | `v1.18.20` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/internal/configsources](https://github.com/aws/aws-sdk-go-v2) | `v1.4.20` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://github.com/aws/aws-sdk-go-v2) | `v2.7.20` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/internal/ini](https://github.com/aws/aws-sdk-go-v2) | `v1.8.6` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/service/ecr](https://github.com/aws/aws-sdk-go-v2) | `v1.55.3` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/service/ecrpublic](https://github.com/aws/aws-sdk-go-v2) | `v1.38.10` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://github.com/aws/aws-sdk-go-v2) | `v1.13.7` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/service/internal/presigned-url](https://github.com/aws/aws-sdk-go-v2) | `v1.13.20` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/service/signin](https://github.com/aws/aws-sdk-go-v2) | `v1.0.8` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/service/sso](https://github.com/aws/aws-sdk-go-v2) | `v1.30.13` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/service/ssooidc](https://github.com/aws/aws-sdk-go-v2) | `v1.35.17` | Apache-2.0 | -| [github.com/aws/aws-sdk-go-v2/service/sts](https://github.com/aws/aws-sdk-go-v2) | `v1.41.9` | Apache-2.0 | -| [github.com/aws/smithy-go](https://github.com/aws/smithy-go) | `v1.24.2` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | `v1.43.0` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream](https://github.com/aws/aws-sdk-go-v2) | `v1.7.14` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `v1.32.31` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) | `v1.19.30` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://github.com/aws/aws-sdk-go-v2) | `v1.18.31` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/feature/s3/manager](https://github.com/aws/aws-sdk-go-v2) | `v1.22.34` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/internal/configsources](https://github.com/aws/aws-sdk-go-v2) | `v1.4.31` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/internal/endpoints/v2](https://github.com/aws/aws-sdk-go-v2) | `v2.7.31` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/internal/v4a](https://github.com/aws/aws-sdk-go-v2) | `v1.4.32` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/ecr](https://github.com/aws/aws-sdk-go-v2) | `v1.56.0` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/ecrpublic](https://github.com/aws/aws-sdk-go-v2) | `v1.38.11` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding](https://github.com/aws/aws-sdk-go-v2) | `v1.13.13` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/internal/checksum](https://github.com/aws/aws-sdk-go-v2) | `v1.9.24` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/internal/presigned-url](https://github.com/aws/aws-sdk-go-v2) | `v1.13.31` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/internal/s3shared](https://github.com/aws/aws-sdk-go-v2) | `v1.19.32` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/kms](https://github.com/aws/aws-sdk-go-v2) | `v1.54.1` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) | `v1.106.0` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/signin](https://github.com/aws/aws-sdk-go-v2) | `v1.5.0` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/sso](https://github.com/aws/aws-sdk-go-v2) | `v1.33.0` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/ssooidc](https://github.com/aws/aws-sdk-go-v2) | `v1.38.0` | Apache-2.0 | +| [github.com/aws/aws-sdk-go-v2/service/sts](https://github.com/aws/aws-sdk-go-v2) | `v1.45.0` | Apache-2.0 | +| [github.com/aws/smithy-go](https://github.com/aws/smithy-go) | `v1.27.4` | Apache-2.0 | | [github.com/awslabs/amazon-ecr-credential-helper/ecr-login](https://github.com/awslabs/amazon-ecr-credential-helper) | `v0.12.0` | Apache-2.0 | | [github.com/beorn7/perks](https://github.com/beorn7/perks) | `v1.0.1` | MIT | +| [github.com/blang/semver](https://github.com/blang/semver) | `v3.5.1+incompatible` | MIT | | [github.com/blang/semver/v4](https://github.com/blang/semver) | `v4.0.0` | MIT | | [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `v4.10.0` | MIT | | [github.com/catppuccin/go](https://github.com/catppuccin/go) | `v0.3.0` | MIT | +| [github.com/cenkalti/backoff/v4](https://github.com/cenkalti/backoff) | `v4.3.0` | MIT | | [github.com/cenkalti/backoff/v5](https://github.com/cenkalti/backoff) | `v5.0.3` | MIT | | [github.com/cespare/xxhash/v2](https://github.com/cespare/xxhash) | `v2.3.0` | MIT | | [github.com/charmbracelet/colorprofile](https://github.com/charmbracelet/colorprofile) | `v0.4.3` | MIT | @@ -70,111 +95,137 @@ To regenerate this file after changing dependencies, run `task cli:licenses`. | [github.com/charmbracelet/x/windows](https://github.com/charmbracelet/x) | `v0.2.2` | MIT | | [github.com/clipperhouse/displaywidth](https://github.com/clipperhouse/displaywidth) | `v0.11.0` | MIT | | [github.com/clipperhouse/uax29/v2](https://github.com/clipperhouse/uax29) | `v2.7.0` | MIT | -| [github.com/coder/websocket](https://github.com/coder/websocket) | `v1.8.12` | ISC | -| [github.com/compose-spec/compose-go/v2](https://github.com/compose-spec/compose-go) | `v2.10.2` | Apache-2.0 | +| [github.com/cloudflare/circl](https://github.com/cloudflare/circl) | `v1.6.4` | BSD-3-Clause | +| [github.com/cncf/xds/go](https://github.com/cncf/xds) | `v0.0.0-20260202195803-dba9d589def2` | Apache-2.0 | +| [github.com/coder/websocket](https://github.com/coder/websocket) | `v1.8.14` | ISC | +| [github.com/compose-spec/compose-go/v2](https://github.com/compose-spec/compose-go) | `v2.14.0` | Apache-2.0 | | [github.com/containerd/console](https://github.com/containerd/console) | `v1.0.5` | Apache-2.0 | -| [github.com/containerd/containerd/api](https://github.com/containerd/containerd) | `v1.10.0` | Apache-2.0 | -| [github.com/containerd/containerd/v2](https://github.com/containerd/containerd) | `v2.2.2` | Apache-2.0 | +| [github.com/containerd/containerd/api](https://github.com/containerd/containerd) | `v1.11.1` | Apache-2.0 | +| [github.com/containerd/containerd/v2](https://github.com/containerd/containerd) | `v2.3.3` | Apache-2.0 | | [github.com/containerd/continuity](https://github.com/containerd/continuity) | `v0.5.0` | Apache-2.0 | | [github.com/containerd/errdefs](https://github.com/containerd/errdefs) | `v1.0.0` | Apache-2.0 | | [github.com/containerd/errdefs/pkg](https://github.com/containerd/errdefs) | `v0.3.0` | Apache-2.0 | | [github.com/containerd/log](https://github.com/containerd/log) | `v0.1.0` | Apache-2.0 | -| [github.com/containerd/platforms](https://github.com/containerd/platforms) | `v1.0.0-rc.2` | Apache-2.0 | -| [github.com/containerd/stargz-snapshotter/estargz](https://github.com/containerd/stargz-snapshotter) | `v0.18.2` | Apache-2.0 | -| [github.com/containerd/ttrpc](https://github.com/containerd/ttrpc) | `v1.2.8` | Apache-2.0 | -| [github.com/containerd/typeurl/v2](https://github.com/containerd/typeurl) | `v2.2.3` | Apache-2.0 | +| [github.com/containerd/platforms](https://github.com/containerd/platforms) | `v1.0.0-rc.4` | Apache-2.0 | +| [github.com/containerd/ttrpc](https://github.com/containerd/ttrpc) | `v1.2.9` | Apache-2.0 | +| [github.com/containerd/typeurl/v2](https://github.com/containerd/typeurl) | `v2.3.0` | Apache-2.0 | | [github.com/containers/image/v5](https://github.com/containers/image) | `v5.36.2` | Apache-2.0 | | [github.com/containers/storage](https://github.com/containers/storage) | `v1.59.1` | Apache-2.0 | | [github.com/coreos/go-semver](https://github.com/coreos/go-semver) | `v0.3.1` | Apache-2.0 | | [github.com/coreos/go-systemd/v22](https://github.com/coreos/go-systemd) | `v22.7.0` | Apache-2.0 | -| [github.com/creachadair/msync](https://github.com/creachadair/msync) | `v0.7.1` | BSD-3-Clause | +| [github.com/cpuguy83/go-md2man/v2](https://github.com/cpuguy83/go-md2man) | `v2.0.7` | MIT | +| [github.com/creachadair/msync](https://github.com/creachadair/msync) | `v0.8.1` | BSD-3-Clause | | [github.com/creack/pty](https://github.com/creack/pty) | `v1.1.24` | MIT | -| [github.com/creativeprojects/go-selfupdate](https://github.com/creativeprojects/go-selfupdate) | `v1.5.2` | MIT | +| [github.com/creativeprojects/go-selfupdate](https://github.com/creativeprojects/go-selfupdate) | `v1.6.0` | MIT | +| [github.com/danieljoos/wincred](https://github.com/danieljoos/wincred) | `v1.2.3` | MIT | | [github.com/davecgh/go-spew](https://github.com/davecgh/go-spew) | `v1.1.2-0.20180830191138-d8f796af33cc` | ISC | | [github.com/davidmz/go-pageant](https://github.com/davidmz/go-pageant) | `v1.0.2` | MIT | | [github.com/dblohm7/wingoes](https://github.com/dblohm7/wingoes) | `v0.0.0-20240119213807-a09d6be7affa` | BSD-3-Clause | | [github.com/devsy-org/admin-apis](https://github.com/devsy-org/admin-apis) | `v1.2.0` | MPL-2.0 | | [github.com/devsy-org/agentapi](https://github.com/devsy-org/agentapi) | `v1.0.1` | MPL-2.0 | | [github.com/devsy-org/api](https://github.com/devsy-org/api) | `v1.1.0` | MPL-2.0 | -| [github.com/devsy-org/apiserver](https://github.com/devsy-org/apiserver) | `v1.5.0` | Apache-2.0 | -| [github.com/devsy-org/ssh](https://github.com/devsy-org/ssh) | `v1.2.0` | BSD-3-Clause | +| [github.com/devsy-org/apiserver](https://github.com/devsy-org/apiserver) | `v1.5.3` | Apache-2.0 | +| [github.com/devsy-org/ssh](https://github.com/devsy-org/ssh) | `v1.2.5` | BSD-3-Clause | | [github.com/distribution/reference](https://github.com/distribution/reference) | `v0.6.0` | Apache-2.0 | -| [github.com/docker/cli](https://github.com/docker/cli) | `v29.6.0+incompatible` | Apache-2.0 | +| [github.com/docker/cli](https://github.com/docker/cli) | `v29.7.1+incompatible` | Apache-2.0 | | [github.com/docker/docker](https://github.com/docker/docker) | `v28.5.2+incompatible` | Apache-2.0 | -| [github.com/docker/docker-credential-helpers](https://github.com/docker/docker-credential-helpers) | `v0.9.5` | MIT | -| [github.com/docker/go-connections](https://github.com/docker/go-connections) | `v0.6.0` | Apache-2.0 | +| [github.com/docker/docker-credential-helpers](https://github.com/docker/docker-credential-helpers) | `v0.9.8` | MIT | +| [github.com/docker/go-connections](https://github.com/docker/go-connections) | `v0.8.1` | Apache-2.0 | | [github.com/docker/go-units](https://github.com/docker/go-units) | `v0.5.0` | Apache-2.0 | | [github.com/dustin/go-humanize](https://github.com/dustin/go-humanize) | `v1.0.1` | MIT | -| [github.com/ebitengine/purego](https://github.com/ebitengine/purego) | `v0.10.0` | Apache-2.0 | +| [github.com/ebitengine/purego](https://github.com/ebitengine/purego) | `v0.10.2` | Apache-2.0 | | [github.com/emicklei/go-restful/v3](https://github.com/emicklei/go-restful) | `v3.13.0` | MIT | +| [github.com/envoyproxy/go-control-plane/envoy](https://github.com/envoyproxy/go-control-plane) | `v1.37.0` | Apache-2.0 | +| [github.com/envoyproxy/protoc-gen-validate](https://github.com/envoyproxy/protoc-gen-validate) | `v1.3.3` | Apache-2.0 | | [github.com/evanphx/json-patch/v5](https://github.com/evanphx/json-patch) | `v5.9.11` | BSD-3-Clause | -| [github.com/felixge/httpsnoop](https://github.com/felixge/httpsnoop) | `v1.0.4` | MIT | -| [github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify) | `v1.9.0` | BSD-3-Clause | +| [github.com/fatih/color](https://github.com/fatih/color) | `v1.19.0` | MIT | +| [github.com/felixge/httpsnoop](https://github.com/felixge/httpsnoop) | `v1.1.0` | MIT | +| [github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify) | `v1.10.1` | BSD-3-Clause | | [github.com/fxamacker/cbor/v2](https://github.com/fxamacker/cbor) | `v2.9.0` | MIT | -| [github.com/gaissmai/bart](https://github.com/gaissmai/bart) | `v0.18.0` | MIT | +| [github.com/gaissmai/bart](https://github.com/gaissmai/bart) | `v0.26.1` | MIT | +| [github.com/getsops/gopgagent](https://github.com/getsops/gopgagent) | `v0.0.0-20241224165529-7044f28e491e` | Apache-2.0 | +| [github.com/getsops/sops/v3](https://github.com/getsops/sops) | `v3.13.3` | MPL-2.0 | | [github.com/go-fed/httpsig](https://github.com/go-fed/httpsig) | `v1.1.0` | BSD-3-Clause | -| [github.com/go-json-experiment/json](https://github.com/go-json-experiment/json) | `v0.0.0-20250813024750-ebf49471dced` | BSD-3-Clause | -| [github.com/go-logr/logr](https://github.com/go-logr/logr) | `v1.4.3` | Apache-2.0 | +| [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) | `v4.1.4` | Apache-2.0 | +| [github.com/go-json-experiment/json](https://github.com/go-json-experiment/json) | `v0.0.0-20260623181947-01eb4420fa68` | BSD-3-Clause | +| [github.com/go-logr/logr](https://github.com/go-logr/logr) | `v1.4.4` | Apache-2.0 | | [github.com/go-logr/stdr](https://github.com/go-logr/stdr) | `v1.2.2` | Apache-2.0 | | [github.com/go-logr/zapr](https://github.com/go-logr/zapr) | `v1.3.0` | Apache-2.0 | | [github.com/go-ole/go-ole](https://github.com/go-ole/go-ole) | `v1.3.0` | MIT | -| [github.com/go-openapi/jsonpointer](https://github.com/go-openapi/jsonpointer) | `v0.22.5` | Apache-2.0 | -| [github.com/go-openapi/jsonreference](https://github.com/go-openapi/jsonreference) | `v0.21.5` | Apache-2.0 | -| [github.com/go-openapi/swag](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/cmdutils](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/conv](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/fileutils](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/jsonname](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/jsonutils](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/loading](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/mangling](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/netutils](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/stringutils](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/typeutils](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | -| [github.com/go-openapi/swag/yamlutils](https://github.com/go-openapi/swag) | `v0.25.5` | Apache-2.0 | +| [github.com/go-openapi/jsonpointer](https://github.com/go-openapi/jsonpointer) | `v0.23.1` | Apache-2.0 | +| [github.com/go-openapi/jsonreference](https://github.com/go-openapi/jsonreference) | `v0.21.6` | Apache-2.0 | +| [github.com/go-openapi/swag](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/cmdutils](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/conv](https://github.com/go-openapi/swag) | `v0.27.0` | Apache-2.0 | +| [github.com/go-openapi/swag/fileutils](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/jsonname](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/jsonutils](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/loading](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/mangling](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/netutils](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/stringutils](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | +| [github.com/go-openapi/swag/typeutils](https://github.com/go-openapi/swag) | `v0.27.0` | Apache-2.0 | +| [github.com/go-openapi/swag/yamlutils](https://github.com/go-openapi/swag) | `v0.26.1` | Apache-2.0 | | [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure) | `v2.5.0` | MIT | -| [github.com/goccy/go-json](https://github.com/goccy/go-json) | `v0.10.5` | MIT | -| [github.com/godbus/dbus/v5](https://github.com/godbus/dbus) | `v5.2.0` | BSD-2-Clause | +| [github.com/goccy/go-json](https://github.com/goccy/go-json) | `v0.10.6` | MIT | +| [github.com/goccy/go-yaml](https://github.com/goccy/go-yaml) | `v1.19.2` | MIT | +| [github.com/godbus/dbus/v5](https://github.com/godbus/dbus) | `v5.2.2` | BSD-2-Clause | | [github.com/gofrs/flock](https://github.com/gofrs/flock) | `v0.13.0` | BSD-3-Clause | | [github.com/gogo/protobuf](https://github.com/gogo/protobuf) | `v1.3.2` | BSD-3-Clause | | [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) | `v5.3.1` | MIT | | [github.com/golang/groupcache](https://github.com/golang/groupcache) | `v0.0.0-20241129210726-2c02b8208cf8` | Apache-2.0 | | [github.com/golang/protobuf](https://github.com/golang/protobuf) | `v1.5.4` | BSD-3-Clause | | [github.com/google/btree](https://github.com/google/btree) | `v1.1.3` | Apache-2.0 | -| [github.com/google/cel-go](https://github.com/google/cel-go) | `v0.26.1` | Apache-2.0 | +| [github.com/google/cel-go](https://github.com/google/cel-go) | `v0.27.0` | Apache-2.0 | | [github.com/google/gnostic-models](https://github.com/google/gnostic-models) | `v0.7.1` | Apache-2.0 | | [github.com/google/go-cmp](https://github.com/google/go-cmp) | `v0.7.0` | BSD-3-Clause | -| [github.com/google/go-containerregistry](https://github.com/google/go-containerregistry) | `v0.21.5` | Apache-2.0 | -| [github.com/google/go-containerregistry/pkg/authn/kubernetes](https://github.com/google/go-containerregistry) | `v0.0.0-20260624172245-e8b2b9e51df6` | Apache-2.0 | -| [github.com/google/go-github/v74](https://github.com/google/go-github) | `v74.0.0` | BSD-3-Clause | -| [github.com/google/go-querystring](https://github.com/google/go-querystring) | `v1.1.0` | BSD-3-Clause | +| [github.com/google/go-containerregistry](https://github.com/google/go-containerregistry) | `v0.21.8` | Apache-2.0 | +| [github.com/google/go-containerregistry/pkg/authn/kubernetes](https://github.com/google/go-containerregistry) | `v0.0.0-20260731170845-2ea098f4b134` | Apache-2.0 | +| [github.com/google/go-github/v86](https://github.com/google/go-github) | `v86.0.0` | BSD-3-Clause | +| [github.com/google/go-github/v90](https://github.com/google/go-github) | `v90.0.0` | BSD-3-Clause | +| [github.com/google/go-querystring](https://github.com/google/go-querystring) | `v1.2.0` | BSD-3-Clause | | [github.com/google/jsonschema-go](https://github.com/google/jsonschema-go) | `v0.4.3` | MIT | +| [github.com/google/s2a-go](https://github.com/google/s2a-go) | `v0.1.9` | Apache-2.0 | | [github.com/google/shlex](https://github.com/google/shlex) | `v0.0.0-20191202100458-e7afc7fbc510` | Apache-2.0 | | [github.com/google/uuid](https://github.com/google/uuid) | `v1.6.0` | BSD-3-Clause | +| [github.com/googleapis/enterprise-certificate-proxy](https://github.com/googleapis/enterprise-certificate-proxy) | `v0.3.18` | Apache-2.0 | +| [github.com/googleapis/gax-go/v2](https://github.com/googleapis/gax-go) | `v2.23.0` | BSD-3-Clause | | [github.com/gorilla/handlers](https://github.com/gorilla/handlers) | `v1.5.2` | BSD-3-Clause | | [github.com/gorilla/websocket](https://github.com/gorilla/websocket) | `v1.5.4-0.20250319132907-e064f32e3674` | BSD-2-Clause | +| [github.com/goware/prefixer](https://github.com/goware/prefixer) | `v0.0.0-20160118172347-395022866408` | MIT | | [github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus](https://github.com/grpc-ecosystem/go-grpc-middleware) | `v1.1.0` | Apache-2.0 | | [github.com/grpc-ecosystem/go-grpc-middleware/v2](https://github.com/grpc-ecosystem/go-grpc-middleware) | `v2.3.3` | Apache-2.0 | -| [github.com/grpc-ecosystem/grpc-gateway/v2](https://github.com/grpc-ecosystem/grpc-gateway) | `v2.27.7` | BSD-3-Clause | +| [github.com/grpc-ecosystem/grpc-gateway/v2](https://github.com/grpc-ecosystem/grpc-gateway) | `v2.29.0` | BSD-3-Clause | +| [github.com/hashicorp/errwrap](https://github.com/hashicorp/errwrap) | `v1.1.0` | MPL-2.0 | | [github.com/hashicorp/go-cleanhttp](https://github.com/hashicorp/go-cleanhttp) | `v0.5.2` | MPL-2.0 | +| [github.com/hashicorp/go-multierror](https://github.com/hashicorp/go-multierror) | `v1.1.1` | MPL-2.0 | | [github.com/hashicorp/go-retryablehttp](https://github.com/hashicorp/go-retryablehttp) | `v0.7.8` | MPL-2.0 | -| [github.com/hashicorp/go-version](https://github.com/hashicorp/go-version) | `v1.8.0` | MPL-2.0 | +| [github.com/hashicorp/go-rootcerts](https://github.com/hashicorp/go-rootcerts) | `v1.0.2` | MPL-2.0 | +| [github.com/hashicorp/go-secure-stdlib/parseutil](https://github.com/hashicorp/go-secure-stdlib) | `v0.2.0` | MPL-2.0 | +| [github.com/hashicorp/go-secure-stdlib/strutil](https://github.com/hashicorp/go-secure-stdlib) | `v0.1.2` | MPL-2.0 | +| [github.com/hashicorp/go-sockaddr](https://github.com/hashicorp/go-sockaddr) | `v1.0.7` | MPL-2.0 | +| [github.com/hashicorp/go-version](https://github.com/hashicorp/go-version) | `v1.9.0` | MPL-2.0 | | [github.com/hashicorp/golang-lru/v2](https://github.com/hashicorp/golang-lru) | `v2.0.7` | MPL-2.0 | +| [github.com/hashicorp/hcl](https://github.com/hashicorp/hcl) | `v1.0.1-vault-7` | MPL-2.0 | +| [github.com/hashicorp/vault/api](https://github.com/hashicorp/vault) | `v1.23.0` | MPL-2.0 | | [github.com/hdevalence/ed25519consensus](https://github.com/hdevalence/ed25519consensus) | `v0.2.0` | BSD-3-Clause | -| [github.com/in-toto/attestation](https://github.com/in-toto/attestation) | `v1.1.2` | Apache-2.0 | -| [github.com/in-toto/in-toto-golang](https://github.com/in-toto/in-toto-golang) | `v0.10.0` | Apache-2.0 | +| [github.com/huaweicloud/huaweicloud-sdk-go-v3](https://github.com/huaweicloud/huaweicloud-sdk-go-v3) | `v0.1.207` | BSD-2-Clause | +| [github.com/huin/goupnp](https://github.com/huin/goupnp) | `v1.3.0` | BSD-2-Clause | +| [github.com/in-toto/attestation](https://github.com/in-toto/attestation) | `v1.2.0` | Apache-2.0 | +| [github.com/in-toto/in-toto-golang](https://github.com/in-toto/in-toto-golang) | `v0.11.0` | Apache-2.0 | | [github.com/inconshreveable/mousetrap](https://github.com/inconshreveable/mousetrap) | `v1.1.0` | Apache-2.0 | | [github.com/joho/godotenv](https://github.com/joho/godotenv) | `v1.5.1` | MIT | -| [github.com/jsimonetti/rtnetlink](https://github.com/jsimonetti/rtnetlink) | `v1.4.0` | MIT | -| [github.com/json-iterator/go](https://github.com/json-iterator/go) | `v1.1.12` | MIT | +| [github.com/jsimonetti/rtnetlink](https://github.com/jsimonetti/rtnetlink) | `v1.4.1` | MIT | +| [github.com/json-iterator/go](https://github.com/json-iterator/go) | `v1.1.13-0.20220915233716-71ac16282d12` | MIT | | [github.com/kballard/go-shellquote](https://github.com/kballard/go-shellquote) | `v0.0.0-20180428030007-95032a82bc51` | MIT | -| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `v1.18.5` | Apache-2.0 | +| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `v1.19.1` | Apache-2.0 | | [github.com/kr/fs](https://github.com/kr/fs) | `v0.1.0` | BSD-3-Clause | | [github.com/kylelemons/godebug](https://github.com/kylelemons/godebug) | `v1.1.0` | Apache-2.0 | +| [github.com/lib/pq](https://github.com/lib/pq) | `v1.12.3` | MIT | | [github.com/liggitt/tabwriter](https://github.com/liggitt/tabwriter) | `v0.0.0-20181228230101-89fcab3d43de` | BSD-3-Clause | | [github.com/lucasb-eyer/go-colorful](https://github.com/lucasb-eyer/go-colorful) | `v1.4.0` | MIT | -| [github.com/mattn/go-colorable](https://github.com/mattn/go-colorable) | `v0.1.14` | MIT | -| [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) | `v0.0.20` | MIT | +| [github.com/mattn/go-colorable](https://github.com/mattn/go-colorable) | `v0.1.15` | MIT | +| [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) | `v0.0.23` | MIT | | [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | `v0.0.23` | MIT | | [github.com/mattn/go-shellwords](https://github.com/mattn/go-shellwords) | `v1.0.12` | MIT | | [github.com/mdlayher/netlink](https://github.com/mdlayher/netlink) | `v1.7.3-0.20250113171957-fbb4dce95f42` | MIT | @@ -184,7 +235,8 @@ To regenerate this file after changing dependencies, run `task cli:licenses`. | [github.com/mitchellh/go-ps](https://github.com/mitchellh/go-ps) | `v1.0.0` | MIT | | [github.com/mitchellh/go-wordwrap](https://github.com/mitchellh/go-wordwrap) | `v1.0.1` | MIT | | [github.com/mitchellh/hashstructure/v2](https://github.com/mitchellh/hashstructure) | `v2.0.2` | MIT | -| [github.com/moby/buildkit](https://github.com/moby/buildkit) | `v0.29.0` | Apache-2.0 | +| [github.com/mitchellh/mapstructure](https://github.com/mitchellh/mapstructure) | `v1.5.1-0.20231216201459-8508981c8b6c` | MIT | +| [github.com/moby/buildkit](https://github.com/moby/buildkit) | `v0.32.2` | Apache-2.0 | | [github.com/moby/docker-image-spec](https://github.com/moby/docker-image-spec) | `v1.3.1` | Apache-2.0 | | [github.com/moby/locker](https://github.com/moby/locker) | `v1.0.1` | Apache-2.0 | | [github.com/moby/patternmatcher](https://github.com/moby/patternmatcher) | `v0.6.1` | Apache-2.0 | @@ -192,133 +244,142 @@ To regenerate this file after changing dependencies, run `task cli:licenses`. | [github.com/moby/sys/capability](https://github.com/moby/sys) | `v0.4.0` | BSD-2-Clause | | [github.com/moby/sys/mountinfo](https://github.com/moby/sys) | `v0.7.2` | Apache-2.0 | | [github.com/moby/sys/signal](https://github.com/moby/sys) | `v0.7.1` | Apache-2.0 | -| [github.com/moby/sys/user](https://github.com/moby/sys) | `v0.4.0` | Apache-2.0 | +| [github.com/moby/sys/user](https://github.com/moby/sys) | `v0.4.1` | Apache-2.0 | | [github.com/moby/term](https://github.com/moby/term) | `v0.5.2` | Apache-2.0 | -| [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) | `v1.6.1` | Apache-2.0 | +| [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) | `v1.7.0` | Apache-2.0 | | [github.com/modern-go/concurrent](https://github.com/modern-go/concurrent) | `v0.0.0-20180306012644-bacd9c7ef1dd` | Apache-2.0 | | [github.com/modern-go/reflect2](https://github.com/modern-go/reflect2) | `v1.0.3-0.20250322232337-35a7c28c31ee` | Apache-2.0 | | [github.com/morikuni/aec](https://github.com/morikuni/aec) | `v1.1.0` | MIT | | [github.com/muesli/cancelreader](https://github.com/muesli/cancelreader) | `v0.2.2` | MIT | | [github.com/munnerz/goautoneg](https://github.com/munnerz/goautoneg) | `v0.0.0-20191010083416-a7dc8b61c822` | BSD-3-Clause | -| [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) | `v2.28.1` | MIT | -| [github.com/onsi/gomega](https://github.com/onsi/gomega) | `v1.39.1` | MIT | +| [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) | `v2.32.0` | MIT | +| [github.com/onsi/gomega](https://github.com/onsi/gomega) | `v1.42.1` | MIT | | [github.com/opencontainers/go-digest](https://github.com/opencontainers/go-digest) | `v1.0.0` | Apache-2.0 | | [github.com/opencontainers/image-spec](https://github.com/opencontainers/image-spec) | `v1.1.1` | Apache-2.0 | | [github.com/opencontainers/runtime-spec](https://github.com/opencontainers/runtime-spec) | `v1.3.0` | Apache-2.0 | | [github.com/pires/go-proxyproto](https://github.com/pires/go-proxyproto) | `v0.8.1` | Apache-2.0 | | [github.com/pkg/browser](https://github.com/pkg/browser) | `v0.0.0-20240102092130-5ac0b6a4141c` | BSD-2-Clause | | [github.com/pkg/errors](https://github.com/pkg/errors) | `v0.9.1` | BSD-2-Clause | -| [github.com/pkg/sftp](https://github.com/pkg/sftp) | `v1.13.10` | BSD-2-Clause | +| [github.com/pkg/sftp](https://github.com/pkg/sftp) | `v1.13.11` | BSD-2-Clause | | [github.com/planetscale/vtprotobuf](https://github.com/planetscale/vtprotobuf) | `v0.6.1-0.20240319094008-0393e58bdf10` | BSD-3-Clause | | [github.com/pmezard/go-difflib](https://github.com/pmezard/go-difflib) | `v1.0.1-0.20181226105442-5d4384ee4fb2` | BSD-3-Clause | -| [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go) | `v1.12.5` | MIT | -| [github.com/prometheus-community/pro-bing](https://github.com/prometheus-community/pro-bing) | `v0.4.0` | MIT | +| [github.com/posthog/posthog-go](https://github.com/posthog/posthog-go) | `v1.22.0` | MIT | | [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) | `v1.23.2` | Apache-2.0 | | [github.com/prometheus/client_model](https://github.com/prometheus/client_model) | `v0.6.2` | Apache-2.0 | -| [github.com/prometheus/common](https://github.com/prometheus/common) | `v0.67.5` | Apache-2.0 | -| [github.com/prometheus/procfs](https://github.com/prometheus/procfs) | `v0.19.2` | Apache-2.0 | +| [github.com/prometheus/common](https://github.com/prometheus/common) | `v0.69.0` | Apache-2.0 | +| [github.com/prometheus/procfs](https://github.com/prometheus/procfs) | `v0.20.1` | Apache-2.0 | | [github.com/rivo/uniseg](https://github.com/rivo/uniseg) | `v0.4.7` | MIT | +| [github.com/russross/blackfriday/v2](https://github.com/russross/blackfriday) | `v2.1.0` | BSD-2-Clause | +| [github.com/ryanuber/go-glob](https://github.com/ryanuber/go-glob) | `v1.0.0` | MIT | | [github.com/safchain/ethtool](https://github.com/safchain/ethtool) | `v0.3.0` | Apache-2.0 | | [github.com/santhosh-tekuri/jsonschema/v6](https://github.com/santhosh-tekuri/jsonschema) | `v6.0.2` | Apache-2.0 | -| [github.com/secure-systems-lab/go-securesystemslib](https://github.com/secure-systems-lab/go-securesystemslib) | `v0.10.0` | MIT | -| [github.com/segmentio/asm](https://github.com/segmentio/asm) | `v1.1.3` | MIT | +| [github.com/secure-systems-lab/go-securesystemslib](https://github.com/secure-systems-lab/go-securesystemslib) | `v0.11.0` | MIT | +| [github.com/segmentio/asm](https://github.com/segmentio/asm) | `v1.2.1` | MIT-0 | | [github.com/segmentio/encoding](https://github.com/segmentio/encoding) | `v0.5.4` | MIT | | [github.com/shibumi/go-pathspec](https://github.com/shibumi/go-pathspec) | `v1.3.0` | Apache-2.0 | -| [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) | `v4.26.4` | BSD-3-Clause | +| [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) | `v4.26.7` | BSD-3-Clause | | [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) | `v1.9.4` | MIT | | [github.com/skratchdot/open-golang](https://github.com/skratchdot/open-golang) | `v0.0.0-20200116055534-eef842397966` | MIT | | [github.com/spf13/cobra](https://github.com/spf13/cobra) | `v1.10.2` | Apache-2.0 | | [github.com/spf13/pflag](https://github.com/spf13/pflag) | `v1.0.10` | BSD-3-Clause | -| [github.com/stoewer/go-strcase](https://github.com/stoewer/go-strcase) | `v1.3.1` | MIT | -| [github.com/stretchr/testify](https://github.com/stretchr/testify) | `v1.11.1` | MIT | -| [github.com/tailscale/certstore](https://github.com/tailscale/certstore) | `v0.1.1-0.20231202035212-d3fa0460f47e` | MIT | +| [github.com/spiffe/go-spiffe/v2](https://github.com/spiffe/go-spiffe) | `v2.8.1` | Apache-2.0 | +| [github.com/stretchr/testify](https://github.com/stretchr/testify) | `v1.12.0` | MIT | +| [github.com/tailscale/certstore](https://github.com/tailscale/certstore) | `v0.1.1-0.20260409135935-3638fb84b77d` | MIT | | [github.com/tailscale/go-winio](https://github.com/tailscale/go-winio) | `v0.0.0-20231025203758-c4f33415bf55` | MIT | -| [github.com/tailscale/goupnp](https://github.com/tailscale/goupnp) | `v1.0.1-0.20210804011211-c64d0f06ea05` | BSD-2-Clause | -| [github.com/tailscale/hujson](https://github.com/tailscale/hujson) | `v0.0.0-20260302212456-ecc657c15afd` | BSD-3-Clause | +| [github.com/tailscale/hujson](https://github.com/tailscale/hujson) | `v0.0.0-20260727124030-b80ff77dac4f` | BSD-3-Clause | | [github.com/tailscale/peercred](https://github.com/tailscale/peercred) | `v0.0.0-20250107143737-35a0c7bd7edc` | BSD-3-Clause | | [github.com/tailscale/web-client-prebuilt](https://github.com/tailscale/web-client-prebuilt) | `v0.0.0-20250124233751-d4cd19a26976` | BSD-3-Clause | -| [github.com/tailscale/wireguard-go](https://github.com/tailscale/wireguard-go) | `v0.0.0-20250716170648-1d0488a3d7da` | MIT | -| [github.com/tidwall/gjson](https://github.com/tidwall/gjson) | `v1.18.0` | MIT | +| [github.com/tailscale/wireguard-go](https://github.com/tailscale/wireguard-go) | `v0.0.0-20260730222847-4affce44577c` | MIT | +| [github.com/tidwall/gjson](https://github.com/tidwall/gjson) | `v1.19.0` | MIT | | [github.com/tidwall/match](https://github.com/tidwall/match) | `v1.1.1` | MIT | | [github.com/tidwall/pretty](https://github.com/tidwall/pretty) | `v1.2.1` | MIT | +| [github.com/tjfoc/gmsm](https://github.com/tjfoc/gmsm) | `v1.4.1` | Apache-2.0 | | [github.com/tklauser/go-sysconf](https://github.com/tklauser/go-sysconf) | `v0.3.16` | BSD-3-Clause | | [github.com/tklauser/numcpus](https://github.com/tklauser/numcpus) | `v0.11.0` | Apache-2.0 | -| [github.com/tonistiigi/fsutil](https://github.com/tonistiigi/fsutil) | `v0.0.0-20260609174605-b61e79c0c046` | MIT | +| [github.com/tonistiigi/fsutil](https://github.com/tonistiigi/fsutil) | `v0.0.0-20260717003753-6d9dc2ebad62` | MIT | | [github.com/tonistiigi/go-csvvalue](https://github.com/tonistiigi/go-csvvalue) | `v0.0.0-20240814133006-030d3b2625d0` | MIT | | [github.com/tonistiigi/units](https://github.com/tonistiigi/units) | `v0.0.0-20180711220420-6950e57a87ea` | MIT | | [github.com/tonistiigi/vt100](https://github.com/tonistiigi/vt100) | `v0.0.0-20240514184818-90bafcd6abab` | MIT | | [github.com/u-root/u-root](https://github.com/u-root/u-root) | `v0.16.0` | BSD-3-Clause | -| [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) | `v0.5.15` | BSD-3-Clause | -| [github.com/vbatts/tar-split](https://github.com/vbatts/tar-split) | `v0.12.2` | BSD-3-Clause | +| [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) | `v0.5.16` | BSD-3-Clause | +| [github.com/urfave/cli](https://github.com/urfave/cli) | `v1.22.17` | MIT | | [github.com/x448/float16](https://github.com/x448/float16) | `v0.8.4` | MIT | | [github.com/xhit/go-str2duration/v2](https://github.com/xhit/go-str2duration) | `v2.1.0` | BSD-3-Clause | | [github.com/xo/terminfo](https://github.com/xo/terminfo) | `v0.0.0-20220910002029-abceb7e1c41e` | MIT | | [github.com/yosida95/uritemplate/v3](https://github.com/yosida95/uritemplate) | `v3.0.2` | BSD-3-Clause | | [github.com/yusufpapurcu/wmi](https://github.com/yusufpapurcu/wmi) | `v1.2.4` | MIT | -| [gitlab.com/gitlab-org/api/client-go](https://gitlab.com/gitlab-org/api/client-go) | `v1.9.1` | Apache-2.0 | +| [github.com/zalando/go-keyring](https://github.com/zalando/go-keyring) | `v0.2.8` | MIT | +| [gitlab.com/gitlab-org/api/client-go](https://gitlab.com/gitlab-org/api/client-go) | `v1.46.0` | Apache-2.0 | | [go.etcd.io/etcd/api/v3](https://go.etcd.io/etcd/api/v3) | `v3.6.8` | Apache-2.0 | | [go.etcd.io/etcd/client/pkg/v3](https://go.etcd.io/etcd/client/pkg/v3) | `v3.6.8` | Apache-2.0 | | [go.etcd.io/etcd/client/v3](https://go.etcd.io/etcd/client/v3) | `v3.6.8` | Apache-2.0 | +| [go.mongodb.org/mongo-driver](https://go.mongodb.org/mongo-driver) | `v1.17.9` | Apache-2.0 | | [go.opentelemetry.io/auto/sdk](https://go.opentelemetry.io/auto/sdk) | `v1.2.1` | Apache-2.0 | -| [go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc](https://go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc) | `v0.65.0` | Apache-2.0 | -| [go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace](https://go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace) | `v0.63.0` | Apache-2.0 | -| [go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp) | `v0.65.0` | Apache-2.0 | -| [go.opentelemetry.io/otel](https://go.opentelemetry.io/otel) | `v1.41.0` | Apache-2.0 | -| [go.opentelemetry.io/otel/exporters/otlp/otlptrace](https://go.opentelemetry.io/otel/exporters/otlp/otlptrace) | `v1.40.0` | Apache-2.0 | -| [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc) | `v1.40.0` | Apache-2.0 | -| [go.opentelemetry.io/otel/metric](https://go.opentelemetry.io/otel/metric) | `v1.41.0` | Apache-2.0 | -| [go.opentelemetry.io/otel/sdk](https://go.opentelemetry.io/otel/sdk) | `v1.40.0` | Apache-2.0 | -| [go.opentelemetry.io/otel/trace](https://go.opentelemetry.io/otel/trace) | `v1.41.0` | Apache-2.0 | -| [go.opentelemetry.io/proto/otlp](https://go.opentelemetry.io/proto/otlp) | `v1.9.0` | Apache-2.0 | +| [go.opentelemetry.io/contrib/detectors/gcp](https://go.opentelemetry.io/contrib/detectors/gcp) | `v1.44.0` | Apache-2.0 | +| [go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc](https://go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc) | `v0.69.0` | Apache-2.0 | +| [go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace](https://go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace) | `v0.69.0` | Apache-2.0 | +| [go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp) | `v0.69.0` | Apache-2.0 | +| [go.opentelemetry.io/otel](https://go.opentelemetry.io/otel) | `v1.44.0` | Apache-2.0 | +| [go.opentelemetry.io/otel/exporters/otlp/otlptrace](https://go.opentelemetry.io/otel/exporters/otlp/otlptrace) | `v1.44.0` | Apache-2.0 | +| [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc) | `v1.44.0` | Apache-2.0 | +| [go.opentelemetry.io/otel/metric](https://go.opentelemetry.io/otel/metric) | `v1.44.0` | Apache-2.0 | +| [go.opentelemetry.io/otel/sdk](https://go.opentelemetry.io/otel/sdk) | `v1.44.0` | Apache-2.0 | +| [go.opentelemetry.io/otel/sdk/metric](https://go.opentelemetry.io/otel/sdk/metric) | `v1.44.0` | Apache-2.0 | +| [go.opentelemetry.io/otel/trace](https://go.opentelemetry.io/otel/trace) | `v1.44.0` | Apache-2.0 | +| [go.opentelemetry.io/proto/otlp](https://go.opentelemetry.io/proto/otlp) | `v1.10.0` | Apache-2.0 | | [go.uber.org/atomic](https://go.uber.org/atomic) | `v1.11.0` | MIT | +| [go.uber.org/goleak](https://go.uber.org/goleak) | `v1.3.0` | MIT | | [go.uber.org/multierr](https://go.uber.org/multierr) | `v1.11.0` | MIT | -| [go.uber.org/zap](https://go.uber.org/zap) | `v1.27.1` | MIT | -| [go.yaml.in/yaml/v2](https://go.yaml.in/yaml/v2) | `v2.4.3` | Apache-2.0 | +| [go.uber.org/zap](https://go.uber.org/zap) | `v1.28.0` | MIT | +| [go.yaml.in/yaml/v2](https://go.yaml.in/yaml/v2) | `v2.4.4` | Apache-2.0 | | [go.yaml.in/yaml/v3](https://go.yaml.in/yaml/v3) | `v3.0.4` | MIT | -| [go.yaml.in/yaml/v4](https://go.yaml.in/yaml/v4) | `v4.0.0-rc.4` | Apache-2.0 | +| [go.yaml.in/yaml/v4](https://go.yaml.in/yaml/v4) | `v4.0.0-rc.6` | Apache-2.0 | | [go4.org/mem](https://go4.org/mem) | `v0.0.0-20240501181205-ae6ca9944745` | Apache-2.0 | | [go4.org/netipx](https://go4.org/netipx) | `v0.0.0-20231129151722-fdeea329fbba` | BSD-3-Clause | -| [golang.org/x/crypto](https://golang.org/x/crypto) | `v0.50.0` | BSD-3-Clause | -| [golang.org/x/exp](https://golang.org/x/exp) | `v0.0.0-20251219203646-944ab1f22d93` | BSD-3-Clause | -| [golang.org/x/mod](https://golang.org/x/mod) | `v0.35.0` | BSD-3-Clause | -| [golang.org/x/net](https://golang.org/x/net) | `v0.53.0` | BSD-3-Clause | +| [golang.org/x/crypto](https://golang.org/x/crypto) | `v0.55.0` | BSD-3-Clause | +| [golang.org/x/exp](https://golang.org/x/exp) | `v0.0.0-20260603202125-055de637280b` | BSD-3-Clause | +| [golang.org/x/mod](https://golang.org/x/mod) | `v0.38.0` | BSD-3-Clause | +| [golang.org/x/net](https://golang.org/x/net) | `v0.57.1-0.20260729233039-99c3b0a8f463` | BSD-3-Clause | | [golang.org/x/oauth2](https://golang.org/x/oauth2) | `v0.36.0` | BSD-3-Clause | -| [golang.org/x/sync](https://golang.org/x/sync) | `v0.20.0` | BSD-3-Clause | -| [golang.org/x/sys](https://golang.org/x/sys) | `v0.46.0` | BSD-3-Clause | -| [golang.org/x/term](https://golang.org/x/term) | `v0.42.0` | BSD-3-Clause | -| [golang.org/x/text](https://golang.org/x/text) | `v0.36.0` | BSD-3-Clause | -| [golang.org/x/time](https://golang.org/x/time) | `v0.14.0` | BSD-3-Clause | -| [golang.org/x/tools](https://golang.org/x/tools) | `v0.44.0` | BSD-3-Clause | +| [golang.org/x/sync](https://golang.org/x/sync) | `v0.22.0` | BSD-3-Clause | +| [golang.org/x/sys](https://golang.org/x/sys) | `v0.47.0` | BSD-3-Clause | +| [golang.org/x/term](https://golang.org/x/term) | `v0.45.0` | BSD-3-Clause | +| [golang.org/x/text](https://golang.org/x/text) | `v0.41.0` | BSD-3-Clause | +| [golang.org/x/time](https://golang.org/x/time) | `v0.15.0` | BSD-3-Clause | +| [golang.org/x/tools](https://golang.org/x/tools) | `v0.48.0` | BSD-3-Clause | | [golang.zx2c4.com/wintun](https://golang.zx2c4.com/wintun) | `v0.0.0-20230126152724-0fa3db229ce2` | MIT | | [golang.zx2c4.com/wireguard/windows](https://golang.zx2c4.com/wireguard/windows) | `v0.5.3` | MIT | | [gomodules.xyz/jsonpatch/v2](https://gomodules.xyz/jsonpatch/v2) | `v2.4.0` | Apache-2.0 | -| [google.golang.org/genproto/googleapis/api](https://google.golang.org/genproto/googleapis/api) | `v0.0.0-20260128011058-8636f8732409` | Apache-2.0 | -| [google.golang.org/genproto/googleapis/rpc](https://google.golang.org/genproto/googleapis/rpc) | `v0.0.0-20260128011058-8636f8732409` | Apache-2.0 | -| [google.golang.org/grpc](https://google.golang.org/grpc) | `v1.80.0` | Apache-2.0 | +| [google.golang.org/api](https://google.golang.org/api) | `v0.289.0` | BSD-3-Clause | +| [google.golang.org/genproto](https://google.golang.org/genproto) | `v0.0.0-20260720171339-e059f2f05d78` | Apache-2.0 | +| [google.golang.org/genproto/googleapis/api](https://google.golang.org/genproto/googleapis/api) | `v0.0.0-20260720171339-e059f2f05d78` | Apache-2.0 | +| [google.golang.org/genproto/googleapis/rpc](https://google.golang.org/genproto/googleapis/rpc) | `v0.0.0-20260720171339-e059f2f05d78` | Apache-2.0 | +| [google.golang.org/grpc](https://google.golang.org/grpc) | `v1.83.0` | Apache-2.0 | | [google.golang.org/protobuf](https://google.golang.org/protobuf) | `v1.36.12-0.20260120151049-f2248ac996af` | BSD-3-Clause | | [gopkg.in/evanphx/json-patch.v4](https://gopkg.in/evanphx/json-patch.v4) | `v4.13.0` | BSD-3-Clause | | [gopkg.in/inf.v0](https://gopkg.in/inf.v0) | `v0.9.1` | BSD-3-Clause | +| [gopkg.in/ini.v1](https://gopkg.in/ini.v1) | `v1.67.3` | Apache-2.0 | | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) | `v3.0.1` | MIT | -| [gvisor.dev/gvisor](https://gvisor.dev/gvisor) | `v0.0.0-20250205023644-9414b50a5633` | Apache-2.0 | -| [k8s.io/api](https://github.com/kubernetes/api) | `v0.36.2` | Apache-2.0 | +| [gvisor.dev/gvisor](https://gvisor.dev/gvisor) | `v0.0.0-20260224225140-573d5e7127a8` | Apache-2.0 | +| [k8s.io/api](https://github.com/kubernetes/api) | `v0.36.3` | Apache-2.0 | | [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `v0.36.2` | Apache-2.0 | -| [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) | `v0.36.2` | Apache-2.0 | -| [k8s.io/apiserver](https://github.com/kubernetes/apiserver) | `v0.36.2` | Apache-2.0 | -| [k8s.io/cli-runtime](https://github.com/kubernetes/cli-runtime) | `v0.36.2` | Apache-2.0 | -| [k8s.io/client-go](https://github.com/kubernetes/client-go) | `v0.36.2` | Apache-2.0 | -| [k8s.io/component-base](https://github.com/kubernetes/component-base) | `v0.36.2` | Apache-2.0 | +| [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) | `v0.36.3` | Apache-2.0 | +| [k8s.io/apiserver](https://github.com/kubernetes/apiserver) | `v0.36.3` | Apache-2.0 | +| [k8s.io/cli-runtime](https://github.com/kubernetes/cli-runtime) | `v0.36.3` | Apache-2.0 | +| [k8s.io/client-go](https://github.com/kubernetes/client-go) | `v0.36.3` | Apache-2.0 | +| [k8s.io/component-base](https://github.com/kubernetes/component-base) | `v0.36.3` | Apache-2.0 | | [k8s.io/klog/v2](https://github.com/kubernetes/klog) | `v2.140.0` | Apache-2.0 | -| [k8s.io/kube-aggregator](https://github.com/kubernetes/kube-aggregator) | `v0.36.2` | Apache-2.0 | -| [k8s.io/kube-openapi](https://github.com/kubernetes/kube-openapi) | `v0.0.0-20260330154417-16be699c7b31` | Apache-2.0 | -| [k8s.io/kubectl](https://github.com/kubernetes/kubectl) | `v0.36.2` | Apache-2.0 | -| [k8s.io/metrics](https://github.com/kubernetes/metrics) | `v0.36.2` | Apache-2.0 | -| [k8s.io/streaming](https://github.com/kubernetes/streaming) | `v0.36.2` | Apache-2.0 | -| [k8s.io/utils](https://github.com/kubernetes/utils) | `v0.0.0-20260319190234-28399d86e0b5` | Apache-2.0 | +| [k8s.io/kube-aggregator](https://github.com/kubernetes/kube-aggregator) | `v0.36.3` | Apache-2.0 | +| [k8s.io/kube-openapi](https://github.com/kubernetes/kube-openapi) | `v0.0.0-20260624041617-8f3fa4921821` | Apache-2.0 | +| [k8s.io/kubectl](https://github.com/kubernetes/kubectl) | `v0.36.3` | Apache-2.0 | +| [k8s.io/metrics](https://github.com/kubernetes/metrics) | `v0.36.3` | Apache-2.0 | +| [k8s.io/streaming](https://github.com/kubernetes/streaming) | `v0.36.3` | Apache-2.0 | +| [k8s.io/utils](https://github.com/kubernetes/utils) | `v0.0.0-20260707023825-cf1189d6abe3` | Apache-2.0 | | [mvdan.cc/sh/v3](https://mvdan.cc/sh/v3) | `v3.13.1` | BSD-3-Clause | | [sigs.k8s.io/apiserver-network-proxy/konnectivity-client](https://sigs.k8s.io/apiserver-network-proxy/konnectivity-client) | `v0.34.0` | Apache-2.0 | | [sigs.k8s.io/controller-runtime](https://sigs.k8s.io/controller-runtime) | `v0.24.1` | Apache-2.0 | | [sigs.k8s.io/json](https://sigs.k8s.io/json) | `v0.0.0-20250730193827-2d320260d730` | Apache-2.0 | | [sigs.k8s.io/randfill](https://sigs.k8s.io/randfill) | `v1.0.0` | Apache-2.0 | -| [sigs.k8s.io/structured-merge-diff/v6](https://sigs.k8s.io/structured-merge-diff/v6) | `v6.3.2` | Apache-2.0 | +| [sigs.k8s.io/structured-merge-diff/v6](https://sigs.k8s.io/structured-merge-diff/v6) | `v6.3.3` | Apache-2.0 | | [sigs.k8s.io/yaml](https://sigs.k8s.io/yaml) | `v1.6.0` | Apache-2.0 | -| [tailscale.com](https://tailscale.com) | `v1.96.5` | BSD-3-Clause | +| [tailscale.com](https://tailscale.com) | `v1.102.2` | BSD-3-Clause | diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go index 4c5c55949..398cc342e 100644 --- a/cmd/ci/ci.go +++ b/cmd/ci/ci.go @@ -139,16 +139,21 @@ func (cmd *CICmd) registerSecretsFlags(ciCmd *cobra.Command) { cliflags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "", "Path to a JSON file containing secret values for features, format: "+ `{"featureId": {"optionName": "value"}}`), - cliflags.StringArray(&cmd.Secrets, names.Secret, nil, - "Stored Devsy secret to inject, as NAME[,type=env|mount][,target=X]; "+ - "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable"), + cliflags.StringArray( + &cmd.Secrets, + names.Secret, + nil, + "Secret to inject: NAME or source-qualified TYPE:SOURCE/NAME[,type=env|mount][,target=X]; "+ + "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable", + ), cliflags.StringArray(&cmd.EnvVars, names.Env, nil, "Stored Devsy env var to inject into the workspace as NAME[=TARGET]. Repeatable"), cliflags.StringArray(&cmd.BuildSecretNames, names.BuildSecret, nil, - "Stored Devsy secret exposed to the build via BuildKit "+ + "Secret to expose to the build via BuildKit; accepts NAME or TYPE:SOURCE/NAME "+ "(RUN --mount=type=secret,id=NAME). Repeatable"), cliflags.String(&cmd.GitTokenSecret, names.GitToken, "", - "Stored Devsy secret holding an access token for cloning a private HTTP repository"), + "Secret holding an access token for cloning a private HTTP repository; "+ + "repository-owned sources cannot bootstrap their own clone"), cliflags.String(&cmd.GitTokenUsername, names.GitTokenUsername, "", "Username for --git-token (default inferred from the repo host)"), ) diff --git a/cmd/secrets/bind.go b/cmd/secrets/bind.go index d11be92c4..302c0e551 100644 --- a/cmd/secrets/bind.go +++ b/cmd/secrets/bind.go @@ -20,7 +20,7 @@ func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command { cmd := &AttachCmd{GlobalFlags: flags} attachCmd := &cobra.Command{ Use: "attach NAME", - Short: "Bind a secret to the active context so it is injected automatically on up", + Short: "Bind a secret reference to the active context so it is injected automatically on up", Args: cobra.ExactArgs(1), RunE: func(cobraCmd *cobra.Command, args []string) error { return cmd.Run(cobraCmd.Context(), args[0]) @@ -30,7 +30,7 @@ func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command { return attachCmd } -func verifySensitive(devsyConfig *config.Config, contextName, name string) error { +func verifyLocalSensitive(devsyConfig *config.Config, contextName, name string) error { store, err := secrets.NewStoreForConfig(devsyConfig) if err != nil { return err @@ -45,10 +45,34 @@ func verifySensitive(devsyConfig *config.Config, contextName, name string) error return nil } -func (cmd *AttachCmd) Run(_ context.Context, name string) error { - if err := secrets.ValidateName(name); err != nil { +func verifySecretReference( + ctx context.Context, + devsyConfig *config.Config, + ref secrets.SecretRef, +) error { + if ref.Source == secrets.LocalSourceName { + return verifyLocalSensitive(devsyConfig, devsyConfig.DefaultContext, ref.Name) + } + resolver, err := secrets.NewResolverForConfig(devsyConfig) + if err != nil { + return err + } + resolved, err := resolver.Resolve(ctx, ref) + if err != nil { + return fmt.Errorf("cannot attach secret %q: %w", ref.String(), err) + } + if !resolved.Sensitive { + return fmt.Errorf("%q is not a sensitive secret", ref.String()) + } + return nil +} + +func (cmd *AttachCmd) Run(ctx context.Context, name string) error { + ref, err := secrets.ParseRef(name) + if err != nil { return err } + canonical := ref.String() devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { @@ -56,7 +80,7 @@ func (cmd *AttachCmd) Run(_ context.Context, name string) error { } contextName := devsyConfig.DefaultContext - if err := verifySensitive(devsyConfig, contextName, name); err != nil { + if err := verifySecretReference(ctx, devsyConfig, ref); err != nil { return err } @@ -64,17 +88,17 @@ func (cmd *AttachCmd) Run(_ context.Context, name string) error { if ctxConfig == nil { return fmt.Errorf("context %q doesn't exist", contextName) } - if slices.Contains(ctxConfig.Secrets, name) { - log.Infof("secret %q already attached to context %q", name, contextName) + if slices.Contains(ctxConfig.Secrets, canonical) { + log.Infof("secret %q already attached to context %q", canonical, contextName) return nil } - ctxConfig.Secrets = append(ctxConfig.Secrets, name) + ctxConfig.Secrets = append(ctxConfig.Secrets, canonical) if err := config.SaveConfig(devsyConfig); err != nil { return fmt.Errorf("save config: %w", err) } - log.Infof("secret %q attached to context %q", name, contextName) + log.Infof("secret %q attached to context %q", canonical, contextName) return nil } @@ -86,7 +110,7 @@ func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command { cmd := &DetachCmd{GlobalFlags: flags} detachCmd := &cobra.Command{ Use: "detach NAME", - Short: "Unbind a secret from the active context", + Short: "Unbind a secret reference from the active context", Args: cobra.ExactArgs(1), RunE: func(cobraCmd *cobra.Command, args []string) error { return cmd.Run(cobraCmd.Context(), args[0]) @@ -97,6 +121,11 @@ func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command { } func (cmd *DetachCmd) Run(_ context.Context, name string) error { + ref, err := secrets.ParseRef(name) + if err != nil { + return err + } + canonical := ref.String() devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err @@ -108,9 +137,9 @@ func (cmd *DetachCmd) Run(_ context.Context, name string) error { return fmt.Errorf("context %q doesn't exist", contextName) } - idx := slices.Index(ctxConfig.Secrets, name) + idx := slices.Index(ctxConfig.Secrets, canonical) if idx < 0 { - log.Infof("secret %q is not attached to context %q", name, contextName) + log.Infof("secret %q is not attached to context %q", canonical, contextName) return nil } ctxConfig.Secrets = slices.Delete(ctxConfig.Secrets, idx, idx+1) @@ -119,6 +148,6 @@ func (cmd *DetachCmd) Run(_ context.Context, name string) error { return fmt.Errorf("save config: %w", err) } - log.Infof("secret %q detached from context %q", name, contextName) + log.Infof("secret %q detached from context %q", canonical, contextName) return nil } diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go index 0a8acf3ed..64a5e1ad9 100644 --- a/cmd/secrets/secrets.go +++ b/cmd/secrets/secrets.go @@ -10,12 +10,13 @@ import ( func NewSecretsCmd(flags *flags.GlobalFlags) *cobra.Command { secretsCmd := &cobra.Command{ Use: "secret", - Short: "Manage secrets", - Long: `Manage named secrets stored locally and injected into workspaces. + Short: "Manage secrets and external secret sources", + Long: `Manage secrets and external secret sources used by Devsy workspaces. -Secret values are kept in the OS keyring (macOS Keychain, Windows Credential -Manager, or libsecret) when available, and in an age-encrypted file otherwise. -Only non-sensitive metadata is stored in the Devsy config directory.`, +Devsy-managed secret values are kept in the OS keyring (macOS Keychain, +Windows Credential Manager, or libsecret) when available, and in an +age-encrypted file otherwise. External sources such as SOPS remain owned by +their encrypted source file and are resolved only when needed.`, } secretsCmd.AddCommand(NewSetCmd(flags)) @@ -24,6 +25,7 @@ Only non-sensitive metadata is stored in the Devsy config directory.`, secretsCmd.AddCommand(NewDeleteCmd(flags)) secretsCmd.AddCommand(NewAttachCmd(flags)) secretsCmd.AddCommand(NewDetachCmd(flags)) + secretsCmd.AddCommand(NewSourceCmd(flags)) return secretsCmd } diff --git a/cmd/secrets/source.go b/cmd/secrets/source.go new file mode 100644 index 000000000..4b5b612b0 --- /dev/null +++ b/cmd/secrets/source.go @@ -0,0 +1,223 @@ +package secrets + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + secrets2 "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +type addSOPSSourceOptions struct { + out io.Writer + globalFlags *flags.GlobalFlags + name string + filePath string + format string +} + +func NewSourceCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &cobra.Command{ + Use: "source", + Short: "Manage external secret sources", + } + cmd.AddCommand(newSourceAddCmd(globalFlags)) + cmd.AddCommand(newSourceListCmd(globalFlags)) + cmd.AddCommand(newSourceRemoveCmd(globalFlags)) + return cmd +} + +func newSourceAddCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + var format string + cmd := &cobra.Command{ + Use: "add sops NAME PATH", + Short: "Register a SOPS-encrypted file as a secret source", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + if args[0] != secrets2.SOPSFormatter { + return fmt.Errorf("unsupported secret source type %q", args[0]) + } + return addSOPSSource(cmd.Context(), addSOPSSourceOptions{ + out: cmd.OutOrStdout(), + globalFlags: globalFlags, + name: args[1], + filePath: args[2], + format: format, + }) + }, + } + cmd.Flags().StringVar( + &format, + "format", + "", + "SOPS document format override (yaml, json, dotenv)", + ) + return cmd +} + +func addSOPSSource(ctx context.Context, opts addSOPSSourceOptions) error { + if err := secrets2.ValidateSourceName(opts.name); err != nil { + return err + } + if opts.name == secrets2.LocalSourceName { + return fmt.Errorf("secret source name %q is reserved", opts.name) + } + absolutePath, err := resolveSOPSFile(opts.filePath) + if err != nil { + return err + } + devsyConfig, err := config.LoadConfig(opts.globalFlags.Context, opts.globalFlags.Provider) + if err != nil { + return err + } + if err := secrets2.NewSOPSSource(opts.name, absolutePath, opts.format). + Validate(ctx); err != nil { + return fmt.Errorf("cannot add SOPS source %q: %w", opts.name, err) + } + if err := persistSOPSSource(devsyConfig, opts.name, absolutePath, opts.format); err != nil { + return err + } + _, err = fmt.Fprintf(opts.out, "Added SOPS secret source %q: %s\n", opts.name, absolutePath) + return err +} + +func resolveSOPSFile(filePath string) (string, error) { + absolutePath, err := filepath.Abs(filePath) + if err != nil { + return "", err + } + absolutePath, err = filepath.EvalSymlinks(absolutePath) + if err != nil { + return "", fmt.Errorf("resolve SOPS source path: %w", err) + } + info, err := os.Stat(absolutePath) + if err != nil { + return "", fmt.Errorf("stat SOPS source path: %w", err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("SOPS source path %q is not a regular file", filePath) + } + return absolutePath, nil +} + +func persistSOPSSource(devsyConfig *config.Config, name, filePath, format string) error { + sources, err := secrets2.LoadSourceConfigs(devsyConfig) + if err != nil { + return err + } + sources, err = secrets2.AddSourceConfig(sources, secrets2.SourceConfig{ + Name: name, + Type: secrets2.SOPSFormatter, + Path: filePath, + Format: format, + }) + if err != nil { + return err + } + return secrets2.SaveSourceConfigs(devsyConfig, sources) +} + +func newSourceListCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List configured external secret sources", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) + if err != nil { + return err + } + sources, err := secrets2.LoadSourceConfigs(devsyConfig) + if err != nil { + return err + } + sort.Slice(sources, func(i, j int) bool { return sources[i].Name < sources[j].Name }) + return writeSourceList(cmd.OutOrStdout(), sources) + }, + } +} + +func writeSourceList(out io.Writer, sources []secrets2.SourceConfig) error { + if len(sources) == 0 { + _, err := fmt.Fprintln(out, "No external secret sources configured.") + return err + } + if _, err := fmt.Fprintln(out, "NAME\tTYPE\tLOCATION"); err != nil { + return err + } + for _, source := range sources { + if _, err := fmt.Fprintf( + out, + "%s\t%s\t%s\n", + source.Name, + source.Type, + source.Path, + ); err != nil { + return err + } + } + return nil +} + +func newSourceRemoveCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + return &cobra.Command{ + Use: "remove NAME", + Short: "Remove an external secret source reference", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return removeSource(cmd.OutOrStdout(), globalFlags, args[0]) + }, + } +} + +func removeSource(out io.Writer, globalFlags *flags.GlobalFlags, name string) error { + devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) + if err != nil { + return err + } + if references := attachedSourceReferences(devsyConfig, name); len(references) > 0 { + return fmt.Errorf( + "cannot remove secret source %q: attached secrets still reference it: %v", + name, + references, + ) + } + if err := removeSourceConfig(devsyConfig, name); err != nil { + return err + } + _, err = fmt.Fprintf(out, "Removed secret source %q\n", name) + return err +} + +func attachedSourceReferences(devsyConfig *config.Config, name string) []string { + current := devsyConfig.Current() + if current == nil { + return nil + } + var references []string + for _, bound := range current.Secrets { + ref, err := secrets2.ParseRef(bound) + if err == nil && ref.Source == name { + references = append(references, bound) + } + } + return references +} + +func removeSourceConfig(devsyConfig *config.Config, name string) error { + sources, err := secrets2.LoadSourceConfigs(devsyConfig) + if err != nil { + return err + } + sources, removed := secrets2.RemoveSourceConfig(sources, name) + if !removed { + return fmt.Errorf("secret source %q is not configured", name) + } + return secrets2.SaveSourceConfigs(devsyConfig, sources) +} diff --git a/cmd/workspace/up/project_secrets.go b/cmd/workspace/up/project_secrets.go new file mode 100644 index 000000000..3d5a40bd4 --- /dev/null +++ b/cmd/workspace/up/project_secrets.go @@ -0,0 +1,176 @@ +package up + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "path/filepath" + + gitpkg "github.com/devsy-org/devsy/pkg/git" + provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/secrets" +) + +type projectSecretContext struct { + config *secrets.ProjectConfig + sources map[string]secrets.Source +} + +func (p *projectSecretContext) attachedSecrets() []string { + if p == nil || p.config == nil { + return nil + } + return p.config.Secrets +} + +func (p *projectSecretContext) register(resolver *secrets.Resolver) error { + if p == nil { + return nil + } + for _, sourceConfig := range p.config.SecretSources { + source := p.sources[sourceConfig.Name] + if source == nil { + return fmt.Errorf("project secret source %q was not loaded", sourceConfig.Name) + } + if err := resolver.Register(sourceConfig.Name, sourceConfig.Type, source); err != nil { + return fmt.Errorf("register project secret source %q: %w", sourceConfig.Name, err) + } + } + return nil +} + +func (cmd *UpCmd) discoverProjectSecrets( + ctx context.Context, + source *provider2.WorkspaceSource, +) (*projectSecretContext, error) { + if source == nil { + return nil, nil + } + if source.LocalFolder != "" { + return discoverLocalProjectSecrets(source.LocalFolder) + } + if source.GitRepository == "" { + return nil, nil + } + return cmd.discoverRemoteProjectSecrets(ctx, source) +} + +func discoverLocalProjectSecrets(root string) (*projectSecretContext, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + cfg, found, err := secrets.LoadProjectConfigFromRoot(root) + if err != nil || !found { + return nil, err + } + project := &projectSecretContext{config: cfg, sources: map[string]secrets.Source{}} + for _, sourceConfig := range cfg.SecretSources { + resolvedPath, err := secrets.ResolveProjectSourcePath(root, sourceConfig.Path) + if err != nil { + return nil, fmt.Errorf("load project secret source %q: %w", sourceConfig.Name, err) + } + project.sources[sourceConfig.Name] = secrets.NewSOPSSource( + sourceConfig.Name, + resolvedPath, + sourceConfig.Format, + ) + } + return project, nil +} + +func (cmd *UpCmd) discoverRemoteProjectSecrets( + ctx context.Context, + source *provider2.WorkspaceSource, +) (*projectSecretContext, error) { + info := &gitpkg.GitInfo{ + Repository: source.GitRepository, + Branch: source.GitBranch, + Commit: source.GitCommit, + PR: source.GitPRReference, + SubPath: source.GitSubPath, + } + inspection, err := gitpkg.InspectRemote(ctx, info, cmd.gitInspectionEnv()) + if err != nil { + return nil, err + } + defer func() { _ = inspection.Close() }() + + configBytes, err := inspection.ReadFile(ctx, secrets.ProjectConfigPath) + if err != nil { + if errors.Is(err, gitpkg.ErrRevisionPathNotFound) { + return nil, nil + } + return nil, fmt.Errorf("discover repository secret configuration: %w", err) + } + cfg, err := secrets.ParseProjectConfig(configBytes) + if err != nil { + return nil, err + } + project := &projectSecretContext{config: cfg, sources: map[string]secrets.Source{}} + for _, sourceConfig := range cfg.SecretSources { + cleanPath, err := secrets.CleanProjectSourcePath(sourceConfig.Path) + if err != nil { + return nil, fmt.Errorf("load project secret source %q: %w", sourceConfig.Name, err) + } + encrypted, err := inspection.ReadFile(ctx, cleanPath) + if err != nil { + return nil, fmt.Errorf( + "load project secret source %q at %q: %w", + sourceConfig.Name, + cleanPath, + err, + ) + } + project.sources[sourceConfig.Name] = secrets.NewSOPSDataSource( + sourceConfig.Name, + cleanPath, + sourceConfig.Format, + encrypted, + ) + } + return project, nil +} + +func (cmd *UpCmd) gitInspectionEnv() []string { + env := gitpkg.GetDefaultExtraEnv(cmd.StrictHostKeyChecking) + if cmd.GitToken == nil || cmd.GitToken.Token == "" { + return env + } + host := cmd.GitToken.Host + if host == "" { + return env + } + username := cmd.GitToken.Username + if username == "" { + username = gitTokenUsernameForHost(host) + } + credential := base64.StdEncoding.EncodeToString([]byte(username + ":" + cmd.GitToken.Token)) + key := "http.https://" + host + "/.extraHeader" + return append(env, + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0="+key, + "GIT_CONFIG_VALUE_0=Authorization: Basic "+credential, + ) +} + +func validateBootstrapSecretReference( + ctx context.Context, + resolver *secrets.Resolver, + value string, +) (secrets.ResolvedSecret, error) { + ref, err := secrets.ParseRef(value) + if err != nil { + return secrets.ResolvedSecret{}, err + } + resolved, err := resolver.Resolve(ctx, ref) + if err != nil { + return secrets.ResolvedSecret{}, fmt.Errorf( + "cannot resolve bootstrap secret %q before repository acquisition: %w", + value, + err, + ) + } + return resolved, nil +} diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go index 1a7bb37a0..454f8c01c 100644 --- a/cmd/workspace/up/secrets_test.go +++ b/cmd/workspace/up/secrets_test.go @@ -1,10 +1,12 @@ package up import ( + "context" "sort" "testing" "github.com/devsy-org/devsy/pkg/config" + secretspkg "github.com/devsy-org/devsy/pkg/secrets" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,8 +26,16 @@ func testConfig(bound ...string) *config.Config { } } +func localRef(name string) secretspkg.SecretRef { + return secretspkg.SecretRef{ + Type: secretspkg.LocalSourceName, + Source: secretspkg.LocalSourceName, + Name: name, + } +} + func sortedRequests(reqs []secretRequest) []secretRequest { - sort.Slice(reqs, func(i, j int) bool { return reqs[i].name < reqs[j].name }) + sort.Slice(reqs, func(i, j int) bool { return reqs[i].ref.String() < reqs[j].ref.String() }) return reqs } @@ -33,82 +43,109 @@ func TestCollectSecretRequests_FlagsDefaultToEnv(t *testing.T) { got, err := collectSecretRequests( []string{secretAPIKey, "DB_PW,target=DATABASE_PASSWORD"}, testConfig(), + nil, ) require.NoError(t, err) assert.Equal(t, []secretRequest{ - {name: secretAPIKey, target: secretAPIKey, mount: false}, - {name: "DB_PW", target: "DATABASE_PASSWORD", mount: false}, + {ref: localRef(secretAPIKey), target: secretAPIKey, mount: false}, + {ref: localRef("DB_PW"), target: "DATABASE_PASSWORD", mount: false}, }, sortedRequests(got)) } -func TestCollectSecretRequests_MountType(t *testing.T) { - got, err := collectSecretRequests([]string{secretTLSKey + ",type=mount"}, testConfig()) - require.NoError(t, err) - assert.Equal(t, []secretRequest{ - {name: secretTLSKey, target: secretTLSKey, mount: true}, - }, got) -} - -func TestCollectSecretRequests_MountWithTarget(t *testing.T) { +func TestCollectSecretRequests_SourceQualified(t *testing.T) { got, err := collectSecretRequests( - []string{secretTLSKey + ",type=mount,target=tls.key"}, + []string{"sops:project/API_KEY,type=mount,target=token"}, testConfig(), + nil, ) require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "sops:project/API_KEY", got[0].ref.String()) + assert.Equal(t, "token", got[0].target) + assert.True(t, got[0].mount) +} + +func TestCollectSecretRequests_ProjectBindings(t *testing.T) { + got, err := collectSecretRequests(nil, testConfig(), []string{"sops:project/API_KEY"}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "sops:project/API_KEY", got[0].ref.String()) + assert.Equal(t, "API_KEY", got[0].target) +} + +func TestCollectSecretRequests_MountType(t *testing.T) { + got, err := collectSecretRequests([]string{secretTLSKey + ",type=mount"}, testConfig(), nil) + require.NoError(t, err) assert.Equal(t, []secretRequest{ - {name: secretTLSKey, target: "tls.key", mount: true}, + {ref: localRef(secretTLSKey), target: secretTLSKey, mount: true}, }, got) } func TestCollectSecretRequests_ContextBindings(t *testing.T) { - got, err := collectSecretRequests(nil, testConfig(secretToken, "SECRET_TWO")) + got, err := collectSecretRequests(nil, testConfig(secretToken, "SECRET_TWO"), nil) require.NoError(t, err) assert.Equal(t, []secretRequest{ - {name: "SECRET_TWO", target: "SECRET_TWO", mount: false}, - {name: secretToken, target: secretToken, mount: false}, + {ref: localRef("SECRET_TWO"), target: "SECRET_TWO", mount: false}, + {ref: localRef(secretToken), target: secretToken, mount: false}, }, sortedRequests(got)) } func TestCollectSecretRequests_FlagOverridesBinding(t *testing.T) { - got, err := collectSecretRequests([]string{"TOKEN,target=CI_TOKEN"}, testConfig(secretToken)) + got, err := collectSecretRequests( + []string{"TOKEN,target=CI_TOKEN"}, + testConfig(secretToken), + nil, + ) require.NoError(t, err) assert.Equal(t, []secretRequest{ - {name: secretToken, target: "CI_TOKEN", mount: false}, + {ref: localRef(secretToken), target: "CI_TOKEN", mount: false}, }, got) } func TestCollectSecretRequests_Empty(t *testing.T) { - got, err := collectSecretRequests(nil, testConfig()) + got, err := collectSecretRequests(nil, testConfig(), nil) require.NoError(t, err) assert.Empty(t, got) } func TestCollectSecretRequests_InvalidType(t *testing.T) { - _, err := collectSecretRequests([]string{"A,type=bogus"}, testConfig()) + _, err := collectSecretRequests([]string{"A,type=bogus"}, testConfig(), nil) require.Error(t, err) } func TestCollectSecretRequests_InvalidOption(t *testing.T) { - _, err := collectSecretRequests([]string{"A,bogus"}, testConfig()) + _, err := collectSecretRequests([]string{"A,bogus"}, testConfig(), nil) require.Error(t, err) } func TestCollectSecretRequests_DuplicateMountTargetRejected(t *testing.T) { _, err := collectSecretRequests( - []string{"A,type=mount,target=tok", "B,type=mount,target=tok"}, + []string{"A,type=mount,target=tok", "sops:project/B,type=mount,target=tok"}, testConfig(), + nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "both mount to target") } +type fixedSource struct { + values map[string]string + sensitive bool +} + +func (s fixedSource) Get(_ context.Context, name string) (secretspkg.ResolvedSecret, error) { + return secretspkg.ResolvedSecret{Name: name, Value: s.values[name], Sensitive: s.sensitive}, nil +} + func TestApplyEnvVars_RejectsSensitiveSecret(t *testing.T) { cmd := &UpCmd{} cmd.EnvVars = []string{secretAPIKey} - get := func(string) (string, error) { return "plaintext", nil } - isSensitive := func(string) (bool, error) { return true, nil } + resolver := secretspkg.NewResolver() + require.NoError(t, resolver.Register("local", "local", fixedSource{ + values: map[string]string{secretAPIKey: "plaintext"}, sensitive: true, + })) - err := cmd.applyEnvVars(get, isSensitive) + err := cmd.applyEnvVars(context.Background(), resolver) require.Error(t, err) assert.Contains(t, err.Error(), "is a secret and cannot be passed with --env") assert.Empty(t, cmd.WorkspaceEnv, "no value must reach the ps-visible WorkspaceEnv") @@ -117,10 +154,42 @@ func TestApplyEnvVars_RejectsSensitiveSecret(t *testing.T) { func TestApplyEnvVars_AllowsNonSensitive(t *testing.T) { cmd := &UpCmd{} cmd.EnvVars = []string{"LOG_LEVEL=DEBUG_TARGET"} - get := func(string) (string, error) { return "debug", nil } - isSensitive := func(string) (bool, error) { return false, nil } + resolver := secretspkg.NewResolver() + require.NoError(t, resolver.Register("local", "local", fixedSource{ + values: map[string]string{"LOG_LEVEL": "debug"}, sensitive: false, + })) - err := cmd.applyEnvVars(get, isSensitive) + err := cmd.applyEnvVars(context.Background(), resolver) require.NoError(t, err) assert.Equal(t, []string{"DEBUG_TARGET=debug"}, cmd.WorkspaceEnv) } + +func TestApplyBuildSecretsUsesUnqualifiedKeyAsID(t *testing.T) { + cmd := &UpCmd{} + cmd.BuildSecretNames = []string{"sops:project/NPM_TOKEN"} + resolver := secretspkg.NewResolver() + require.NoError(t, resolver.Register("project", "sops", fixedSource{ + values: map[string]string{"NPM_TOKEN": "secret"}, sensitive: true, + })) + + err := cmd.applyBuildSecrets(context.Background(), resolver) + require.NoError(t, err) + assert.Equal(t, []string{"NPM_TOKEN=secret"}, cmd.BuildSecrets) +} + +func TestApplyBuildSecretsRejectsDuplicateIDs(t *testing.T) { + cmd := &UpCmd{} + cmd.BuildSecretNames = []string{"sops:project/NPM_TOKEN", "sops:other/NPM_TOKEN"} + resolver := secretspkg.NewResolver() + require.NoError(t, resolver.Register("project", "sops", fixedSource{ + values: map[string]string{"NPM_TOKEN": "secret-a"}, sensitive: true, + })) + require.NoError(t, resolver.Register("other", "sops", fixedSource{ + values: map[string]string{"NPM_TOKEN": "secret-b"}, sensitive: true, + })) + + err := cmd.applyBuildSecrets(context.Background(), resolver) + require.Error(t, err) + assert.Contains(t, err.Error(), "both use BuildKit id \"NPM_TOKEN\"") + assert.Empty(t, cmd.BuildSecrets) +} diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index c49c22c99..ff042d10b 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -72,9 +72,6 @@ func (cmd *UpCmd) prepareClient( log.Debug("using error output stream") config.MergeContextOptions(devsyConfig.Current(), os.Environ()) } - if err := cmd.prepareSecrets(devsyConfig); err != nil { - return nil, err - } if err := cmd.validateFromSnapshot(ctx, args); err != nil { return nil, err } @@ -82,6 +79,21 @@ func (cmd *UpCmd) prepareClient( if err != nil { return nil, err } + + // Bootstrap credentials are resolved exclusively from sources that are + // available before repository acquisition. Repository-owned sources are + // deliberately not registered yet, which prevents circular clone auth. + if err := cmd.prepareBootstrapGitToken(ctx, devsyConfig, source); err != nil { + return nil, err + } + projectSecrets, err := cmd.discoverProjectSecrets(ctx, source) + if err != nil { + return nil, err + } + if err := cmd.prepareSecretsWithProject(ctx, devsyConfig, projectSecrets); err != nil { + return nil, err + } + cmd.resolveSSHConfig(devsyConfig) args = cmd.ensureArgsForFromSnapshot(args) @@ -115,13 +127,6 @@ func (cmd *UpCmd) checkProviderUpdate( // ensureArgsForFromSnapshot returns args unchanged unless --from-snapshot is // set and args is empty, in which case it synthesizes a placeholder arg. -// resolveWorkspace only takes its create-new-workspace path when args is -// non-empty (see pkg/workspace.resolveWorkspace); --from-snapshot forbids a -// positional source (validateFromSnapshot), so without this, a -// --from-snapshot restore into a workspace id that doesn't exist yet always -// fails with "doesn't exist" instead of creating it. The synthesized value -// itself is never read on this path: DesiredID and Source (both already set -// by resolveExplicitSource) take priority over it. func (cmd *UpCmd) ensureArgsForFromSnapshot(args []string) []string { if cmd.FromSnapshot != "" && len(args) == 0 { return []string{cmd.FromSnapshot} @@ -154,7 +159,17 @@ func (cmd *UpCmd) resolveParams( } } +// prepareSecrets preserves the headless/internal call path where repository +// discovery has already happened elsewhere or is not applicable. func (cmd *UpCmd) prepareSecrets(devsyConfig *config.Config) error { + return cmd.prepareSecretsWithProject(context.Background(), devsyConfig, nil) +} + +func (cmd *UpCmd) prepareSecretsWithProject( + ctx context.Context, + devsyConfig *config.Config, + project *projectSecretContext, +) error { if err := mergeEnvFromFiles(&cmd.CLIOptions); err != nil { return err } @@ -169,7 +184,7 @@ func (cmd *UpCmd) prepareSecrets(devsyConfig *config.Config) error { } } - if err := cmd.resolveStoredSecrets(devsyConfig); err != nil { + if err := cmd.resolveStoredSecrets(ctx, devsyConfig, project); err != nil { return err } @@ -189,83 +204,87 @@ func (cmd *UpCmd) prepareSecrets(devsyConfig *config.Config) error { return nil } -func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { - requests, err := collectSecretRequests(cmd.Secrets, devsyConfig) +func (cmd *UpCmd) prepareBootstrapGitToken( + ctx context.Context, + devsyConfig *config.Config, + source *provider2.WorkspaceSource, +) error { + if cmd.GitTokenSecret == "" { + return nil + } + resolver, err := secrets.NewResolverForConfig(devsyConfig) if err != nil { return err } - if !cmd.hasStoredValues(requests) { - return nil + resolved, err := validateBootstrapSecretReference(ctx, resolver, cmd.GitTokenSecret) + if err != nil { + return err } - - store, err := secrets.NewStoreForConfig(devsyConfig) + gitToken, err := cmd.buildGitTokenForSource(resolved.Value, source) if err != nil { return err } - r := secretResolver{store: store, context: devsyConfig.DefaultContext} + cmd.GitToken = gitToken + return nil +} - if err := cmd.applyLifecycleSecrets(requests, r.get); err != nil { +func (cmd *UpCmd) resolveStoredSecrets( + ctx context.Context, + devsyConfig *config.Config, + project *projectSecretContext, +) error { + requests, err := collectSecretRequests(cmd.Secrets, devsyConfig, project.attachedSecrets()) + if err != nil { return err } - if err := cmd.applyEnvVars(r.get, r.sensitive); err != nil { + if !cmd.hasStoredValues(requests) { + return nil + } + + resolver, err := secrets.NewResolverForConfig(devsyConfig) + if err != nil { return err } - if err := cmd.applyBuildSecrets(r.get); err != nil { + if err := project.register(resolver); err != nil { return err } - return cmd.applyGitToken(r.get) -} -type secretResolver struct { - store secrets.Store - context string -} - -func (r secretResolver) get(name string) (string, error) { - value, err := r.store.Get(r.context, name) - if err != nil { - return "", fmt.Errorf("resolve secret %q in context %q: %w", name, r.context, err) + if err := cmd.applyLifecycleSecrets(ctx, requests, resolver); err != nil { + return err } - return value, nil -} - -func (r secretResolver) sensitive(name string) (bool, error) { - meta, err := r.store.Meta(r.context, name) - if err != nil { - return false, fmt.Errorf("resolve secret %q in context %q: %w", name, r.context, err) + if err := cmd.applyEnvVars(ctx, resolver); err != nil { + return err } - return meta.Sensitive(), nil + return cmd.applyBuildSecrets(ctx, resolver) } func (cmd *UpCmd) hasStoredValues(requests []secretRequest) bool { - return len(requests) > 0 || len(cmd.EnvVars) > 0 || - len(cmd.BuildSecretNames) > 0 || cmd.GitTokenSecret != "" + return len(requests) > 0 || len(cmd.EnvVars) > 0 || len(cmd.BuildSecretNames) > 0 } func (cmd *UpCmd) applyLifecycleSecrets( + ctx context.Context, requests []secretRequest, - get func(string) (string, error), + resolver *secrets.Resolver, ) error { for _, req := range requests { - value, err := get(req.name) + resolved, err := resolver.Resolve(ctx, req.ref) if err != nil { return err } if req.mount { - cmd.SecretsMount = append(cmd.SecretsMount, req.target+"="+value) + cmd.SecretsMount = append(cmd.SecretsMount, req.target+"="+resolved.Value) } else { - cmd.SecretsEnv = append(cmd.SecretsEnv, req.target+"="+value) + cmd.SecretsEnv = append(cmd.SecretsEnv, req.target+"="+resolved.Value) } } return nil } -// applyEnvVars resolves --env entries into WorkspaceEnv. A sensitive secret must -// never be routed here: WorkspaceEnv rides in the setup argv (ps-visible). -func (cmd *UpCmd) applyEnvVars( - get func(string) (string, error), - isSensitive func(string) (bool, error), -) error { +// applyEnvVars resolves --env entries from the local Devsy store. External +// sensitive sources intentionally use --secret instead: WorkspaceEnv rides in +// the setup argv and is process-list visible. +func (cmd *UpCmd) applyEnvVars(ctx context.Context, resolver *secrets.Resolver) error { for _, entry := range cmd.EnvVars { name, target, ok := strings.Cut(entry, "=") if !ok { @@ -273,57 +292,70 @@ func (cmd *UpCmd) applyEnvVars( } else if target == "" { return fmt.Errorf("invalid --env %q: target after %q= must not be empty", entry, name) } - sensitive, err := isSensitive(name) + ref, err := secrets.ParseRef(name) if err != nil { return err } - if sensitive { + if ref.Source != secrets.LocalSourceName { return fmt.Errorf( - "%q is a secret and cannot be passed with --env (it would be visible in the process list); use --secret %s instead", - name, + "--env only accepts Devsy-managed values; use --secret %s instead", name, ) } - value, err := get(name) + resolved, err := resolver.Resolve(ctx, ref) if err != nil { return err } - cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, target+"="+value) + if resolved.Sensitive { + return fmt.Errorf( + "%q is a secret and cannot be passed with --env (it would be visible in the process list); use --secret %s instead", + name, + name, + ) + } + cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, target+"="+resolved.Value) } return nil } -func (cmd *UpCmd) applyBuildSecrets(get func(string) (string, error)) error { - for _, name := range cmd.BuildSecretNames { - value, err := get(name) +func (cmd *UpCmd) applyBuildSecrets(ctx context.Context, resolver *secrets.Resolver) error { + seen := map[string]string{} + built := make([]string, 0, len(cmd.BuildSecretNames)) + for _, value := range cmd.BuildSecretNames { + ref, err := secrets.ParseRef(value) + if err != nil { + return err + } + if other, dup := seen[ref.Name]; dup { + return fmt.Errorf( + "build secrets %q and %q both use BuildKit id %q", + other, + ref.String(), + ref.Name, + ) + } + seen[ref.Name] = ref.String() + resolved, err := resolver.Resolve(ctx, ref) if err != nil { return err } - cmd.BuildSecrets = append(cmd.BuildSecrets, name+"="+value) + built = append(built, ref.Name+"="+resolved.Value) } + cmd.BuildSecrets = built return nil } -func (cmd *UpCmd) applyGitToken(get func(string) (string, error)) error { - if cmd.GitTokenSecret == "" { - return nil +func (cmd *UpCmd) buildGitTokenForSource( + token string, + source *provider2.WorkspaceSource, +) (*provider2.GitToken, error) { + host := "" + if source != nil && source.GitRepository != "" { + host = gitHostFromSource(source.GitRepository) } - token, err := get(cmd.GitTokenSecret) - if err != nil { - return err - } - gitToken, err := cmd.buildGitToken(token) - if err != nil { - return err + if host == "" { + host = gitHostFromSource(cmd.Source) } - cmd.GitToken = gitToken - return nil -} - -// buildGitToken host-scopes the token; it fails on an unresolvable host so the -// token is never left unscoped. -func (cmd *UpCmd) buildGitToken(token string) (*provider2.GitToken, error) { - host := gitHostFromSource(cmd.Source) if host == "" { return nil, fmt.Errorf( "cannot use --git-token: workspace source %q has no HTTP(S) host to scope the token to", @@ -334,12 +366,11 @@ func (cmd *UpCmd) buildGitToken(token string) (*provider2.GitToken, error) { if username == "" { username = gitTokenUsernameForHost(host) } - return &provider2.GitToken{Host: host, Username: username, Token: token}, nil } // gitHostFromSource returns the host of an HTTP(S) git source, or "". A git -// token is only usable over HTTP(S), so non-HTTP(S) schemes (e.g. ssh) yield "". +// token is only usable over HTTP(S), so non-HTTP(S) schemes yield "". func gitHostFromSource(source string) string { s := strings.TrimPrefix(source, "git:") u, err := url.Parse(s) @@ -360,40 +391,76 @@ func gitTokenUsernameForHost(host string) string { } type secretRequest struct { - name string + ref secrets.SecretRef target string mount bool } -// collectSecretRequests merges context bindings with secret flags; flags override bindings. -func collectSecretRequests(flags []string, devsyConfig *config.Config) ([]secretRequest, error) { +// collectSecretRequests merges context/project bindings with secret flags; +// explicit flags override automatic bindings for the same reference. +func collectSecretRequests( + flags []string, + devsyConfig *config.Config, + projectSecrets []string, +) ([]secretRequest, error) { byName := map[string]secretRequest{} + var attached []string if ctxConfig := devsyConfig.Contexts[devsyConfig.DefaultContext]; ctxConfig != nil { - for _, name := range ctxConfig.Secrets { - byName[name] = secretRequest{name: name, target: name} + attached = ctxConfig.Secrets + } + if err := addSecretBindings(byName, attached, "attached"); err != nil { + return nil, err + } + if err := addSecretBindings(byName, projectSecrets, "project"); err != nil { + return nil, err + } + if err := addSecretFlags(byName, flags); err != nil { + return nil, err + } + + requests := sortedSecretRequests(byName) + if err := checkDuplicateMountTargets(requests); err != nil { + return nil, err + } + return requests, nil +} + +func addSecretBindings( + byName map[string]secretRequest, + values []string, + kind string, +) error { + for _, value := range values { + ref, err := secrets.ParseRef(value) + if err != nil { + return fmt.Errorf("invalid %s secret %q: %w", kind, value, err) } + byName[ref.String()] = secretRequest{ref: ref, target: ref.Name} } + return nil +} - for _, entry := range flags { - req, err := parseSecretFlag(entry) +func addSecretFlags(byName map[string]secretRequest, values []string) error { + for _, value := range values { + req, err := parseSecretFlag(value) if err != nil { - return nil, err + return err } - byName[req.name] = req + byName[req.ref.String()] = req } + return nil +} +func sortedSecretRequests(byName map[string]secretRequest) []secretRequest { requests := make([]secretRequest, 0, len(byName)) for _, req := range byName { requests = append(requests, req) } - sort.Slice(requests, func(i, j int) bool { return requests[i].name < requests[j].name }) - - if err := checkDuplicateMountTargets(requests); err != nil { - return nil, err - } - - return requests, nil + sort.Slice(requests, func(i, j int) bool { + return requests[i].ref.String() < requests[j].ref.String() + }) + return requests } func checkDuplicateMountTargets(requests []secretRequest) error { @@ -405,20 +472,21 @@ func checkDuplicateMountTargets(requests []secretRequest) error { if other, dup := targets[req.target]; dup { return fmt.Errorf( "secrets %q and %q both mount to target %q; give one a distinct target=", - other, req.name, req.target, + other, req.ref.String(), req.target, ) } - targets[req.target] = req.name + targets[req.target] = req.ref.String() } return nil } func parseSecretFlag(entry string) (secretRequest, error) { parts := strings.Split(entry, ",") - req := secretRequest{name: parts[0]} - if req.name == "" { - return secretRequest{}, fmt.Errorf("invalid secret %q: missing name", entry) + ref, err := secrets.ParseRef(parts[0]) + if err != nil { + return secretRequest{}, err } + req := secretRequest{ref: ref} for _, opt := range parts[1:] { key, value, ok := strings.Cut(opt, "=") @@ -434,9 +502,8 @@ func parseSecretFlag(entry string) (secretRequest, error) { } if req.target == "" { - req.target = req.name + req.target = req.ref.Name } - return req, nil } @@ -459,7 +526,6 @@ func (req *secretRequest) applyOption(key, value string) error { default: return fmt.Errorf("invalid secret option %q, expected type or target", key) } - return nil } @@ -488,21 +554,6 @@ func (cmd *UpCmd) parseWorkspaceSource() (*provider2.WorkspaceSource, error) { return source, nil } -// resolveExplicitSource returns an explicit WorkspaceSource when --from-snapshot -// is set, composed identically to `devsy snapshot restore` via -// snapshot.RestoreComposition ("snapshot:" source, "image::-fs" -// DevContainerSource), taking priority over positional-arg source resolution. -// It also sets cmd.DevContainerSource so the workspace runs the snapshot's -// committed filesystem image instead of rebuilding, matching restore's -// behavior exactly. -// -// Since --from-snapshot forbids a positional source (validateFromSnapshot), -// there is no other way for the workspace ID to reach ResolveParams.DesiredID -// on this path; without defaulting it here, workspace resolution falls back -// to selecting an unrelated existing workspace (or fails confusingly in -// non-TTY contexts). So when --id wasn't given explicitly, default it from -// the snapshot ref's workspace id, mirroring `devsy snapshot restore`'s -// buildWorkspace. func (cmd *UpCmd) resolveExplicitSource() (*provider2.WorkspaceSource, error) { if cmd.FromSnapshot == "" { return nil, nil @@ -531,14 +582,6 @@ func (cmd *UpCmd) resolveExplicitSource() (*provider2.WorkspaceSource, error) { return source, nil } -// validateFromSnapshot enforces --from-snapshot's invariants before workspace -// resolution: it cannot be combined with an explicit source (positional arg -// or --source) or used in platform mode (snapshots are local-only — `devsy -// snapshot create` rejects machine-provider workspaces up front, and restore -// has no remote-registry-backed equivalent of a platform-managed container), -// and the referenced snapshot must actually exist. Mirrors `devsy snapshot -// restore`'s upfront PullManifest check so a bad or missing ref fails fast -// with a clear error instead of partway through workspace creation. func (cmd *UpCmd) validateFromSnapshot(ctx context.Context, args []string) error { if cmd.FromSnapshot == "" { return nil @@ -556,10 +599,6 @@ func (cmd *UpCmd) validateFromSnapshot(ctx context.Context, args []string) error return cmd.applyFromSnapshotOverrides(manifest) } -// applyFromSnapshotOverrides replays the create-time devcontainer.json -// settings the snapshot's manifest carries (runArgs, containerEnv, -// remoteUser) onto cmd, so the image-sourced restored container behaves like -// the original did. func (cmd *UpCmd) applyFromSnapshotOverrides(manifest *snapshotpkg.Manifest) error { runArgs, err := manifest.RunArgs() if err != nil { diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index 20a8675ec..e944228a1 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -224,16 +224,21 @@ func (cmd *UpCmd) registerWorkspaceSecretsFlags(upCmd *cobra.Command) { "File of env vars for the workspace"), flags.String(&cmd.SecretsFile, names.SecretsFile, "", `JSON file ({"KEY":"value"}) of secrets for lifecycle commands`), - flags.StringArray(&cmd.Secrets, names.Secret, nil, - "Stored Devsy secret to inject, as NAME[,type=env|mount][,target=X]; "+ - "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable"), + flags.StringArray( + &cmd.Secrets, + names.Secret, + nil, + "Secret to inject: NAME or source-qualified TYPE:SOURCE/NAME[,type=env|mount][,target=X]; "+ + "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable", + ), flags.StringArray(&cmd.EnvVars, names.Env, nil, "Stored Devsy env var to inject into the workspace as NAME[=TARGET]. Repeatable"), flags.StringArray(&cmd.BuildSecretNames, names.BuildSecret, nil, - "Stored Devsy secret exposed to the build via BuildKit "+ + "Secret to expose to the build via BuildKit; accepts NAME or TYPE:SOURCE/NAME "+ "(RUN --mount=type=secret,id=NAME). Repeatable"), flags.String(&cmd.GitTokenSecret, names.GitToken, "", - "Stored Devsy secret holding an access token for cloning a private HTTP repository"), + "Secret holding an access token for cloning a private HTTP repository; "+ + "repository-owned sources cannot bootstrap their own clone"), flags.String(&cmd.GitTokenUsername, names.GitTokenUsername, "", "Username for --git-token (default inferred from the repo host)"), flags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "", diff --git a/e2e/tests/up/sops.go b/e2e/tests/up/sops.go new file mode 100644 index 000000000..965d84eb1 --- /dev/null +++ b/e2e/tests/up/sops.go @@ -0,0 +1,162 @@ +package up + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + docker "github.com/devsy-org/devsy/pkg/docker" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + sopsE2EPlaintext = "SUPER_SECRET_TEST_VALUE_7B91" + sopsE2EMounted = "mounted-value-77" + sopsE2EAgeIdentity = "AGE-SECRET-KEY-12UWYSAH2MRDQ5K4EWC4253PDTCSCS32Y5EFQ8TEN2SL3QYU2GN2SG88CZX" // gitleaks:allow +) + +var _ = ginkgo.Describe( + "SOPS secret sources", + ginkgo.Label("up-provider-docker"), + func() { + var dtc *dockerTestContext + + ginkgo.BeforeEach(func(ctx context.Context) { + var err error + dtc = &dockerTestContext{} + dtc.initialDir, err = os.Getwd() + framework.ExpectNoError(err) + + dtc.dockerHelper = &docker.DockerHelper{DockerCommand: testDockerCommand} + dtc.f, err = setupDockerProvider( + filepath.Join(dtc.initialDir, "bin"), + testDockerCommand, + ) + framework.ExpectNoError(err) + }) + + ginkgo.It( + "injects a registered SOPS source as env and mounted secrets", + func(ctx context.Context) { + useSOPSAgeIdentity() + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-sops-source", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + registerSOPSSource(ctx, dtc, "sops-e2e", filepath.Join(tempDir, "secrets.enc.yaml")) + err = dtc.f.DevsyUp( + ctx, + tempDir, + "--secret", + "sops:sops-e2e/SOPS_E2E_SECRET,target=SOPS_ALIAS", + "--secret", + "sops:sops-e2e/TLS_KEY,type=mount,target=tls_key", + ) + framework.ExpectNoError(err) + + envValue, err := dtc.execSSH(ctx, tempDir, "cat /tmp/sops-env-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(envValue)).To(gomega.Equal(sopsE2EPlaintext)) + + mountValue, err := dtc.execSSH(ctx, tempDir, "cat /tmp/sops-mount-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(mountValue)).To(gomega.Equal(sopsE2EMounted)) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "discovers repository-owned SOPS sources and attached secrets", + func(ctx context.Context) { + useSOPSAgeIdentity() + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-sops-project", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + err = dtc.f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + value, err := dtc.execSSH(ctx, tempDir, "cat /tmp/sops-project-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(value)).To(gomega.Equal(sopsE2EPlaintext)) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "fails closed when SOPS credentials are unavailable without leaking plaintext", + func(ctx context.Context) { + useSOPSAgeIdentity() + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-sops-source", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + registerSOPSSource( + ctx, + dtc, + "sops-failure", + filepath.Join(tempDir, "secrets.enc.yaml"), + ) + framework.ExpectNoError(os.Setenv("SOPS_AGE_KEY", "")) + framework.ExpectNoError( + os.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(tempDir, "missing-age-key")), + ) + + stdout, stderr, upErr := dtc.f.DevsyUpStreams( + ctx, + tempDir, + "--secret", + "sops:sops-failure/SOPS_E2E_SECRET", + ) + gomega.Expect(upErr).To(gomega.HaveOccurred()) + + combined := strings.Join([]string{stdout, stderr, fmt.Sprint(upErr)}, "\n") + gomega.Expect(combined).NotTo(gomega.ContainSubstring(sopsE2EPlaintext)) + gomega.Expect(combined).NotTo(gomega.ContainSubstring(sopsE2EMounted)) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + }, +) + +func useSOPSAgeIdentity() { + setSOPSEnv("SOPS_AGE_KEY", sopsE2EAgeIdentity) + setSOPSEnv("SOPS_AGE_KEY_FILE", "") + setSOPSEnv("SOPS_AGE_KEY_CMD", "") +} + +func setSOPSEnv(name, value string) { + previous, had := os.LookupEnv(name) + framework.ExpectNoError(os.Setenv(name, value)) + ginkgo.DeferCleanup(func() { + if had { + _ = os.Setenv(name, previous) + return + } + _ = os.Unsetenv(name) + }) +} + +func registerSOPSSource(ctx context.Context, dtc *dockerTestContext, name, filePath string) { + _, err := dtc.f.ExecCommandOutput( + ctx, + []string{secretCmd, "source", "add", "sops", name, filePath}, + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { + _, _ = dtc.f.ExecCommandOutput(ctx, []string{secretCmd, "source", "remove", name}) + }) +} diff --git a/e2e/tests/up/testdata/docker-sops-project/.devcontainer.json b/e2e/tests/up/testdata/docker-sops-project/.devcontainer.json new file mode 100644 index 000000000..89b5b5169 --- /dev/null +++ b/e2e/tests/up/testdata/docker-sops-project/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "postCreateCommand": "printf '%s' \"$SOPS_E2E_SECRET\" > /tmp/sops-project-check.out" +} diff --git a/e2e/tests/up/testdata/docker-sops-project/.devsy/config.yaml b/e2e/tests/up/testdata/docker-sops-project/.devsy/config.yaml new file mode 100644 index 000000000..5251ffe8a --- /dev/null +++ b/e2e/tests/up/testdata/docker-sops-project/.devsy/config.yaml @@ -0,0 +1,6 @@ +secretSources: + - name: project + type: sops + path: secrets.enc.yaml +secrets: + - sops:project/SOPS_E2E_SECRET diff --git a/e2e/tests/up/testdata/docker-sops-project/secrets.enc.yaml b/e2e/tests/up/testdata/docker-sops-project/secrets.enc.yaml new file mode 100644 index 000000000..79a5fdfd8 --- /dev/null +++ b/e2e/tests/up/testdata/docker-sops-project/secrets.enc.yaml @@ -0,0 +1,17 @@ +SOPS_E2E_SECRET: ENC[AES256_GCM,data:7/LkTGbUbM24m2YegXZhCPwFErYDhnRC3jflQA==,iv:jox9Lg827lNean0A3dbFnjbizHq/+e5Oj41ntzFKtNc=,tag:H0XcdWPIYBmte+v0F9j47Q==,type:str] +TLS_KEY: ENC[AES256_GCM,data:7qUD3Paq3RT0XbF/qvSnfw==,iv:ClkYTnncQRPn3JhDZ9iZfuY0lNDYY71ot6HRjF2HCVY=,tag:oGoczTS56kPXL9N4/FY+nA==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwWHBIUkxaRWlsdFRidWFF + OHdHTHZGZUh2UGRzOXhVdFlxRnZIZkY3eTFnCnpoTFNrNVZKcFRtTVhjbnBUK1c1 + SGFnd2R0ZGQvOHFueWpHR0xiOGVvdTAKLS0tIDJTSVFCazNueGJkMlZDUU9vK21j + d0xPTEV4NVZGL1EweHBqM3lTSG9sb3MKJtSnyqRMtWM2WRIEJSHQPkYtBXa+wSpG + PXamDLnUGIrzsjbl6uTZxHytVSwfVERwWzagJTEOajIJpFwHIyGD/A== + -----END AGE ENCRYPTED FILE----- + recipient: age1h84qv0p8kdwfp6vrag4nvmpd6gt3mde9g6dcnj9uryh3qcl50uxq83n932 + lastmodified: "2026-09-03T05:38:40Z" + mac: ENC[AES256_GCM,data:5thaKGP8s7dbN/9VvRn54TB0R6UaRlRvtyoby0yn5gGlCAYZLvBlAXmTY8AQ0ShQII3ictP2KpXVLGd0dp0H0LXGr9PpsdXIVEO5t/WS5YO+ZhdxZ5hgWBzEL1rZ2VO9Bt0DPFmEYNNryqIpLU64lbd+AO5P/6/uaBMZZERYE0Y=,iv:7nUHdzpyZidt2DRQdvSv2W2GCF5JArFzvyX9ppVEP5k=,tag:/KTQAguMfRBpWPDumiTunA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.3 diff --git a/e2e/tests/up/testdata/docker-sops-source/.devcontainer.json b/e2e/tests/up/testdata/docker-sops-source/.devcontainer.json new file mode 100644 index 000000000..ccaca4ac3 --- /dev/null +++ b/e2e/tests/up/testdata/docker-sops-source/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "postCreateCommand": "printf '%s' \"$SOPS_ALIAS\" > /tmp/sops-env-check.out && cat /run/secrets/tls_key > /tmp/sops-mount-check.out" +} diff --git a/e2e/tests/up/testdata/docker-sops-source/secrets.enc.yaml b/e2e/tests/up/testdata/docker-sops-source/secrets.enc.yaml new file mode 100644 index 000000000..79a5fdfd8 --- /dev/null +++ b/e2e/tests/up/testdata/docker-sops-source/secrets.enc.yaml @@ -0,0 +1,17 @@ +SOPS_E2E_SECRET: ENC[AES256_GCM,data:7/LkTGbUbM24m2YegXZhCPwFErYDhnRC3jflQA==,iv:jox9Lg827lNean0A3dbFnjbizHq/+e5Oj41ntzFKtNc=,tag:H0XcdWPIYBmte+v0F9j47Q==,type:str] +TLS_KEY: ENC[AES256_GCM,data:7qUD3Paq3RT0XbF/qvSnfw==,iv:ClkYTnncQRPn3JhDZ9iZfuY0lNDYY71ot6HRjF2HCVY=,tag:oGoczTS56kPXL9N4/FY+nA==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwWHBIUkxaRWlsdFRidWFF + OHdHTHZGZUh2UGRzOXhVdFlxRnZIZkY3eTFnCnpoTFNrNVZKcFRtTVhjbnBUK1c1 + SGFnd2R0ZGQvOHFueWpHR0xiOGVvdTAKLS0tIDJTSVFCazNueGJkMlZDUU9vK21j + d0xPTEV4NVZGL1EweHBqM3lTSG9sb3MKJtSnyqRMtWM2WRIEJSHQPkYtBXa+wSpG + PXamDLnUGIrzsjbl6uTZxHytVSwfVERwWzagJTEOajIJpFwHIyGD/A== + -----END AGE ENCRYPTED FILE----- + recipient: age1h84qv0p8kdwfp6vrag4nvmpd6gt3mde9g6dcnj9uryh3qcl50uxq83n932 + lastmodified: "2026-09-03T05:38:40Z" + mac: ENC[AES256_GCM,data:5thaKGP8s7dbN/9VvRn54TB0R6UaRlRvtyoby0yn5gGlCAYZLvBlAXmTY8AQ0ShQII3ictP2KpXVLGd0dp0H0LXGr9PpsdXIVEO5t/WS5YO+ZhdxZ5hgWBzEL1rZ2VO9Bt0DPFmEYNNryqIpLU64lbd+AO5P/6/uaBMZZERYE0Y=,iv:7nUHdzpyZidt2DRQdvSv2W2GCF5JArFzvyX9ppVEP5k=,tag:/KTQAguMfRBpWPDumiTunA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.3 diff --git a/go.mod b/go.mod index a605aa684..649349709 100644 --- a/go.mod +++ b/go.mod @@ -23,13 +23,14 @@ require ( github.com/devsy-org/agentapi v1.0.1 github.com/devsy-org/api v1.1.0 github.com/devsy-org/apiserver v1.5.3 - github.com/devsy-org/ssh v1.2.7 + github.com/devsy-org/ssh v1.2.5 github.com/distribution/reference v0.6.0 github.com/docker/cli v29.7.1+incompatible github.com/docker/docker v28.5.2+incompatible github.com/docker/docker-credential-helpers v0.9.8 github.com/docker/go-connections v0.8.1 github.com/evanphx/json-patch/v5 v5.9.11 + github.com/getsops/sops/v3 v3.13.3 github.com/go-logr/logr v1.4.4 github.com/go-logr/zapr v1.3.0 github.com/gofrs/flock v0.13.0 @@ -63,7 +64,7 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.0 go.uber.org/zap v1.28.0 - golang.org/x/crypto v0.56.0 + golang.org/x/crypto v0.55.0 golang.org/x/mod v0.38.0 golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 @@ -91,14 +92,14 @@ require ( charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth v0.22.0 // 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/iam v1.11.0 // indirect - cloud.google.com/go/kms v1.31.0 // indirect - cloud.google.com/go/longrunning v1.0.0 // indirect - cloud.google.com/go/monitoring v1.25.0 // indirect - cloud.google.com/go/storage v1.62.2 // indirect + cloud.google.com/go/iam v1.12.0 // indirect + cloud.google.com/go/kms v1.32.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 dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect @@ -121,9 +122,9 @@ 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/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // 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/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect @@ -145,7 +146,7 @@ require ( 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/aws/aws-sdk-go-v2/feature/s3/manager v1.21.1 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5 // 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 @@ -156,7 +157,7 @@ require ( 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/kms v1.52.0 // 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 @@ -192,7 +193,7 @@ require ( github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect + github.com/cloudflare/circl v1.6.4 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/containerd/console v1.0.5 // indirect @@ -209,6 +210,7 @@ require ( github.com/coreos/go-oidc/v3 v3.20.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.7.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/creachadair/msync v0.8.1 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect @@ -230,11 +232,13 @@ require ( 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/felixge/httpsnoop v1.0.4 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gaissmai/bart v0.26.1 // indirect + github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e // indirect github.com/github/smimesign v0.2.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -271,6 +275,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -290,14 +295,15 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/wire v0.7.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect - github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/goreleaser/chglog v0.7.4 // indirect github.com/goreleaser/fileglob v1.4.0 // indirect github.com/goreleaser/go-shellwords v1.0.13 // indirect github.com/goreleaser/goreleaser/v2 v2.17.1 // indirect github.com/goreleaser/nfpm/v2 v2.47.0 // indirect github.com/goreleaser/quill v0.0.0-20260630015114-8310f3e9a321 // indirect + github.com/goware/prefixer v0.0.0-20160118172347-395022866408 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -305,11 +311,18 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // 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/hdevalence/ed25519consensus v0.2.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.207 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect @@ -331,7 +344,7 @@ require ( github.com/jbenet/goprocess v0.1.4 // indirect github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect github.com/jsimonetti/rtnetlink v1.4.1 // indirect - github.com/json-iterator/go v1.1.12 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.19.1 // indirect @@ -339,6 +352,7 @@ require ( github.com/klauspost/pgzip v1.2.6 // indirect github.com/kr/fs v0.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lib/pq v1.12.3 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -356,6 +370,7 @@ require ( github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // 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/locker v1.0.1 // indirect @@ -403,6 +418,8 @@ require ( github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect @@ -429,7 +446,7 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/viper v1.21.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect @@ -440,6 +457,7 @@ require ( github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e // indirect @@ -449,6 +467,7 @@ require ( 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/urfave/cli v1.22.17 // indirect github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c // indirect github.com/x448/float16 v0.8.4 // indirect @@ -464,6 +483,7 @@ require ( go.etcd.io/etcd/api/v3 v3.6.8 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.8 // indirect go.etcd.io/etcd/client/v3 v3.6.8 // indirect + go.mongodb.org/mongo-driver v1.17.9 // indirect go.mozilla.org/pkcs7 v0.9.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect @@ -496,14 +516,15 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/api v0.283.0 // indirect - google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/api v0.289.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-20260720171339-e059f2f05d78 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // 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/ini.v1 v1.67.3 // indirect gopkg.in/mail.v2 v2.3.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect diff --git a/go.sum b/go.sum index ae3ca8bb7..9d91c28e9 100644 --- a/go.sum +++ b/go.sum @@ -14,28 +14,29 @@ charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= -cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= +cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= -cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= -cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= -cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= -cloud.google.com/go/logging v1.14.0 h1:xpPpY8cVT6n9DgIRgrWyE+YEsGlO/994pWnbc7o5Eh4= -cloud.google.com/go/logging v1.14.0/go.mod h1:jmI+Try/fZeOTOAer3wVYOuPf9WX9PyzhlSDoBAi4HM= -cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= -cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= -cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4= -cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= -cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= -cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= -cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ= -cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg= +cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= +cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= +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/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q= +cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= +cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= +cloud.google.com/go/storage v1.63.1 h1:CYXILV9G4CH0C18IQ9+V0h4XiqD2LhKnMLO0o7uJWNs= +cloud.google.com/go/storage v1.63.1/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE= code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -111,18 +112,19 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0 h1:yzIYdwuro811Z27D3T80Wkd3rqZzb0K43nner7Eh1yE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.58.0 h1:ZYGajzJNcirVZpT1rltgf9iM+j9zZ4v8V9DrF+xKRJ8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.58.0/go.mod h1:PDQyYBOzGtQgvshQI//UiXyzuMHCz0ndyu+4W8X82vM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.58.0 h1:IBF8BbhKJkMsON/eY+LMu3aF3XMiotCb9KvkUmEkOJo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.58.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.58.0 h1:SBZzZCiPmDrUV7NSCWY54OnKikO/oTydPCvyEyYaDDE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.58.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -191,8 +193,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oY github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= 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/s3/manager v1.21.1 h1:1hWFp+52Vq8Fevy/KUhbW/1MEApMz7uitCF/PQXRJpk= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.1/go.mod h1:sIec8j802/rCkCKgZV678HFR0s7lhQUYXT77tIvlaa4= +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/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= @@ -213,8 +215,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92 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/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= -github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +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= @@ -277,6 +279,7 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -314,12 +317,14 @@ github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb2 github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= +github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= @@ -374,6 +379,8 @@ github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creachadair/mds v0.25.13 h1:PsSUHV6zsfPd29k4kvm1rMoee1YFia7JyNGeMPmDcPM= github.com/creachadair/mds v0.25.13/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM= @@ -407,8 +414,8 @@ github.com/devsy-org/api v1.1.0 h1:l7T9k7RVwatwN4lxeDTF3iN6EYmfGZgR3ZTJMDGha1M= github.com/devsy-org/api v1.1.0/go.mod h1:mAZklKdnywJYiXDReBLte/H+3m69z6G7RHB3n1lI53Q= github.com/devsy-org/apiserver v1.5.3 h1:tFKMgPxxfvojJ+C+wo0oqSmbE9PTJ2ur1E2yFj0eISI= github.com/devsy-org/apiserver v1.5.3/go.mod h1:m7gpbrh++Hp8iEM5jaP7vjjODPbs9Bmh3l4+iPFg1jE= -github.com/devsy-org/ssh v1.2.7 h1:tD4Sg4CrGsBwltMc5GbUWVJ8HIJp+GcI7J2WTP+j0Qk= -github.com/devsy-org/ssh v1.2.7/go.mod h1:GRrtJWdXPG2cXxcwIO1/IHM/3YnUlEqiCMdTIB4ejSs= +github.com/devsy-org/ssh v1.2.5 h1:Z7gTanYs2ZslT1swTw4leoVVuDEmuNNhQi48W2kNqMU= +github.com/devsy-org/ssh v1.2.5/go.mod h1:6r5tZ+H9JFoMl6NrxXRgltg9HhEryTd69avY831Lio8= github.com/devsy-org/tailscale v1.102.2 h1:9SB6htvO+HmG8alal8WGCshHapfHR7dUFRSMYiFIzIM= github.com/devsy-org/tailscale v1.102.2/go.mod h1:kQUA0lYb/bqCJJZzShx+gqLSxggEFgXB4VDVZDzxWc8= github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb h1:7ENzkH+O3juL+yj2undESLTaAeRllHwCs/b8z6aWSfc= @@ -462,24 +469,28 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= @@ -490,6 +501,10 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e h1:y/1nzrdF+RPds4lfoEpNhjfmzlgZtPqyO3jMzrqDQws= +github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e/go.mod h1:awFzISqLJoZLm+i9QQ4SgMNHDqljH6jWV0B36V5MrUM= +github.com/getsops/sops/v3 v3.13.3 h1:saYczbT88kD1saNChe1cAbFQe5mrRhTIfEw3TaEcmK0= +github.com/getsops/sops/v3 v3.13.3/go.mod h1:3mUuUtKnJ63IzIvU4LQoDXdp0ZvorY5s2hEc7UVNfx8= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/github/smimesign v0.2.0 h1:Hho4YcX5N1I9XNqhq0fNx0Sts8MhLonHd+HRXVGNjvk= @@ -578,6 +593,10 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg github.com/go-openapi/testify/v2 v2.6.0/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-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSrdV7Nv3/gkvc= @@ -599,8 +618,9 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= @@ -614,10 +634,22 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= @@ -626,6 +658,10 @@ github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -676,10 +712,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= -github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= -github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= -github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= -github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +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/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= @@ -702,6 +738,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/goware/prefixer v0.0.0-20160118172347-395022866408 h1:Y9iQJfEqnN3/Nce9cOegemcy/9Ai5k3huT6E80F3zaw= +github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukgRPJ7bJ9a1fdfQ9j8i/cEcRAoLZzbxYpNB/s= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= @@ -740,8 +778,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= -github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= -github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= @@ -750,6 +788,8 @@ github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.207 h1:lgMtpjpIWPw0gbCAko23dRKl66ZPUmeAOidjKFkub2E= +github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.207/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d h1:nP8SfQJqruIVSWYJTuYc37jLHEY1Z0fF+zKSrs3K/C8= github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= @@ -812,8 +852,8 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE= github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -824,6 +864,7 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 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/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -843,8 +884,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= @@ -856,9 +900,11 @@ github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRH github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= @@ -941,6 +987,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= @@ -1025,6 +1072,7 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= @@ -1039,6 +1087,7 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= @@ -1128,8 +1177,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= -github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -1146,6 +1195,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= @@ -1198,6 +1248,8 @@ github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC6 github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= @@ -1227,6 +1279,8 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17 github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= +github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= github.com/vbatts/tar-split v0.12.3 h1:Cd46rkGXI3Td4yrVNwU8ripbxFaQbmesqhjBUUYAJSw= github.com/vbatts/tar-split v0.12.3/go.mod h1:sQOc6OlqGCr7HkGx/IDBeKiTIvqhmj8KffNhEXG4Nq0= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -1241,6 +1295,9 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= @@ -1259,6 +1316,7 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= @@ -1299,6 +1357,9 @@ go.etcd.io/etcd/server/v3 v3.6.8 h1:U2strdSEy1U8qcSzRIdkYpvOPtBy/9i/IfaaCI9flZ4= go.etcd.io/etcd/server/v3 v3.6.8/go.mod h1:88dCtwUnSirkUoJbflQxxWXqtBSZa6lSG0Kuej+dois= go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= +go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= @@ -1337,8 +1398,8 @@ go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252 go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= @@ -1392,19 +1453,24 @@ golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +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/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -1414,10 +1480,14 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +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-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= @@ -1427,8 +1497,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463 h1:56+vNxUDjzdwAJjglL/DMnp31nd8UeytlPRLbZfz55Q= golang.org/x/net v0.57.1-0.20260729233039-99c3b0a8f463/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 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/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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1437,11 +1510,14 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1452,6 +1528,7 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1472,9 +1549,11 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -1484,8 +1563,11 @@ golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -1513,18 +1595,32 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= -google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= -google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek= -google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/api v0.289.0 h1:DmH0c6NigNFmsvsohM9bxv+MzVhag3aGHnojA5fFQjc= +google.golang.org/api v0.289.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20260720171339-e059f2f05d78 h1:NO3LCWyMAM/f/RDLvCC8B/NEvuYqOQAP12XWoyB4os8= +google.golang.org/genproto v0.0.0-20260720171339-e059f2f05d78/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260720171339-e059f2f05d78 h1:A6tVI++lXZuQiRnz7E+iFluPQ+silVmlkbryjSO1z8c= +google.golang.org/genproto/googleapis/api v0.0.0-20260720171339-e059f2f05d78/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720171339-e059f2f05d78 h1:pRUrsnNVD/NpCD42WJ2AO3dQ2s1e2sqMxg8jOwdX2Ak= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720171339-e059f2f05d78/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 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/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= @@ -1539,6 +1635,9 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= @@ -1559,6 +1658,8 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.8.0-rc.1 h1:wqMm2kjcEXMOr+6yau+pdKqJKe6l2N1aKPkpini+Kzk= honnef.co/go/tools v0.8.0-rc.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= diff --git a/pkg/git/inspect.go b/pkg/git/inspect.go new file mode 100644 index 000000000..592898da4 --- /dev/null +++ b/pkg/git/inspect.go @@ -0,0 +1,139 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const bloblessCloneFilter = "--filter=blob:none" + +var ErrRevisionPathNotFound = errors.New("path not found in git revision") + +// Inspection is a lightweight, blobless, no-checkout clone used to inspect a +// small number of repository-owned configuration files before workspace build. +type Inspection struct { + repo *Repo + rev string + root string +} + +// InspectRemote creates a temporary blobless clone and selects the exact +// revision described by info without checking out the worktree. +func InspectRemote(ctx context.Context, info *GitInfo, env []string) (*Inspection, error) { + if info == nil || info.Repository == "" { + return nil, fmt.Errorf("git repository is empty") + } + root, err := os.MkdirTemp("", "devsy-repo-inspect-*") + if err != nil { + return nil, err + } + + repo, err := cloneInspectionRepo(ctx, root, info, env) + if err != nil { + _ = os.RemoveAll(root) + return nil, err + } + rev, err := selectInspectionRevision(ctx, repo, info) + if err != nil { + _ = os.RemoveAll(root) + return nil, err + } + return &Inspection{repo: repo, rev: rev, root: root}, nil +} + +func cloneInspectionRepo( + ctx context.Context, + root string, + info *GitInfo, + env []string, +) (*Repo, error) { + target := filepath.Join(root, "repo") + args := []string{"clone", bloblessCloneFilter, "--no-checkout", "--depth=1"} + if info.Branch != "" { + args = append(args, "--branch", info.Branch) + } + args = append(args, info.Repository, target) + + bootstrap := At("", WithEnv(env)) + if _, err := bootstrap.runner.Run(ctx, RunOptions{Env: bootstrap.env, Args: args}); err != nil { + return nil, fmt.Errorf("inspect remote repository: %w", err) + } + return At(target, WithEnv(env)), nil +} + +func selectInspectionRevision(ctx context.Context, repo *Repo, info *GitInfo) (string, error) { + if info.PR != "" { + return fetchInspectionPR(ctx, repo, info.Repository, info.PR) + } + if info.Commit != "" { + return fetchInspectionCommit(ctx, repo, info.Commit) + } + return "HEAD", nil +} + +func fetchInspectionPR( + ctx context.Context, + repo *Repo, + repository, request string, +) (string, error) { + number := prNumber(request) + if number == "" { + return "", fmt.Errorf("invalid pull/merge request reference %q", request) + } + var lastErr error + for _, host := range prCandidates(repository) { + refspec := host.Refspec(number) + if _, err := repo.run(ctx, "fetch", "--depth=1", "origin", refspec); err == nil { + return "FETCH_HEAD", nil + } else { + lastErr = err + } + } + return "", fmt.Errorf("fetch request revision: %w", lastErr) +} + +func fetchInspectionCommit(ctx context.Context, repo *Repo, commit string) (string, error) { + if _, err := repo.run(ctx, "fetch", "--depth=1", "origin", commit); err != nil { + return "", fmt.Errorf("fetch commit %q: %w", commit, err) + } + return "FETCH_HEAD", nil +} + +// ReadFile returns the bytes for a repository-relative path at the exact +// revision selected by InspectRemote. +func (i *Inspection) ReadFile(ctx context.Context, filePath string) ([]byte, error) { + if i == nil || i.repo == nil { + return nil, fmt.Errorf("git inspection is closed") + } + filePath = strings.TrimPrefix(strings.ReplaceAll(filePath, "\\", "/"), "./") + object := i.rev + ":" + filePath + if _, err := i.repo.run(ctx, "cat-file", "-e", object); err != nil { + return nil, fmt.Errorf("%w: %s", ErrRevisionPathNotFound, filePath) + } + result, err := i.repo.run(ctx, "show", object) + if err != nil { + return nil, fmt.Errorf("read %q from revision %s: %w", filePath, i.rev, err) + } + return append([]byte(nil), result.Stdout...), nil +} + +func (i *Inspection) Revision() string { + if i == nil { + return "" + } + return i.rev +} + +func (i *Inspection) Close() error { + if i == nil || i.root == "" { + return nil + } + root := i.root + i.root = "" + i.repo = nil + return os.RemoveAll(root) +} diff --git a/pkg/git/repo_test.go b/pkg/git/repo_test.go index bf0cc1dac..7b089d7dc 100644 --- a/pkg/git/repo_test.go +++ b/pkg/git/repo_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + "github.com/devsy-org/devsy/pkg/command" "gotest.tools/assert" "gotest.tools/assert/cmp" ) @@ -55,6 +56,13 @@ func (f *fakeRunner) lastArgs() []string { return f.calls[len(f.calls)-1].Args } +func expectedCloneArgs(base ...string) []string { + if !command.Exists(binGitLFS) { + return append(append([]string{}, base...), lfsDisableFilterArgs...) + } + return append([]string{}, base...) +} + func TestRepoFetch(t *testing.T) { fake := &fakeRunner{} repo := At("/tmp/repo", WithRunner(fake)) @@ -195,10 +203,10 @@ func TestRepoCloneArgsThroughRunner(t *testing.T) { WithBranch(testBranch), ) assert.NilError(t, err) - assert.DeepEqual(t, []string{ + assert.DeepEqual(t, expectedCloneArgs( subClone, "--depth=1", flagBranch, testBranch, testRepoURL, testTarget, flagProgress, - }, fake.lastArgs()) + ), fake.lastArgs()) } func TestRepoEnvThreadedToRunner(t *testing.T) { @@ -231,10 +239,10 @@ func TestRepoCloneFromInfoBranch(t *testing.T) { assert.NilError(t, repo.CloneFromInfo(context.Background(), info, "")) // Branch becomes a clone flag; no separate checkout call. - assert.DeepEqual(t, []string{ + assert.DeepEqual(t, expectedCloneArgs( subClone, flagBranch, testBranch, testRepoURL, testTarget, flagProgress, - }, fake.calls[0].Args) + ), fake.calls[0].Args) } func TestRepoCloneFromInfoCommit(t *testing.T) { @@ -265,8 +273,8 @@ func TestRepoCloneFromInfoHelper(t *testing.T) { info := &GitInfo{Repository: "https://host/org/repo.git"} assert.NilError(t, repo.CloneFromInfo(context.Background(), info, "store")) - assert.DeepEqual(t, []string{ + assert.DeepEqual(t, expectedCloneArgs( subClone, flagConfig, "credential.helper=store", "https://host/org/repo.git", testTarget, flagProgress, - }, fake.calls[0].Args) + ), fake.calls[0].Args) } diff --git a/pkg/secrets/local_source.go b/pkg/secrets/local_source.go new file mode 100644 index 000000000..0187299ae --- /dev/null +++ b/pkg/secrets/local_source.go @@ -0,0 +1,36 @@ +package secrets + +import ( + "context" + "fmt" +) + +// LocalSource adapts the existing Devsy Store to the generic source interface. +type LocalSource struct { + store Store + context string +} + +func NewLocalSource(store Store, contextName string) *LocalSource { + return &LocalSource{store: store, context: contextName} +} + +func (s *LocalSource) Get(_ context.Context, name string) (ResolvedSecret, error) { + if s == nil || s.store == nil { + return ResolvedSecret{}, fmt.Errorf("local secret source is not configured") + } + value, err := s.store.Get(s.context, name) + if err != nil { + return ResolvedSecret{}, err + } + meta, err := s.store.Meta(s.context, name) + if err != nil { + return ResolvedSecret{}, err + } + return ResolvedSecret{ + Name: name, + Value: value, + Sensitive: meta.Sensitive(), + Source: LocalSourceName, + }, nil +} diff --git a/pkg/secrets/project_config.go b/pkg/secrets/project_config.go new file mode 100644 index 000000000..c9cfbbc9b --- /dev/null +++ b/pkg/secrets/project_config.go @@ -0,0 +1,191 @@ +package secrets + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "sigs.k8s.io/yaml" +) + +const ProjectConfigPath = ".devsy/config.yaml" + +// ProjectConfig is the repository-owned subset of Devsy configuration used by +// secret discovery. It is intentionally declarative and does not execute code. +type ProjectConfig struct { + SecretSources []SourceConfig `json:"secretSources,omitempty" yaml:"secretSources,omitempty"` + Secrets []string `json:"secrets,omitempty" yaml:"secrets,omitempty"` +} + +func ParseProjectConfig(data []byte) (*ProjectConfig, error) { + cfg := &ProjectConfig{} + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", ProjectConfigPath, err) + } + if err := ValidateProjectConfig(cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func ValidateProjectConfig(cfg *ProjectConfig) error { + if cfg == nil { + return nil + } + seen, err := validateProjectSources(cfg.SecretSources) + if err != nil { + return err + } + return validateProjectSecrets(cfg.Secrets, seen) +} + +func validateProjectSources(sources []SourceConfig) (map[string]struct{}, error) { + seen := make(map[string]struct{}, len(sources)) + for i, source := range sources { + if err := validateProjectSource(i, source, seen); err != nil { + return nil, err + } + seen[source.Name] = struct{}{} + } + return seen, nil +} + +func validateProjectSource(index int, source SourceConfig, seen map[string]struct{}) error { + if err := ValidateSourceName(source.Name); err != nil { + return fmt.Errorf("secretSources[%d]: %w", index, err) + } + if source.Name == LocalSourceName { + return fmt.Errorf("secretSources[%d]: source name %q is reserved", index, LocalSourceName) + } + if source.Type != SOPSFormatter { + return fmt.Errorf("secretSources[%d]: unsupported source type %q", index, source.Type) + } + if _, exists := seen[source.Name]; exists { + return fmt.Errorf("duplicate project secret source %q", source.Name) + } + if _, err := CleanProjectSourcePath(source.Path); err != nil { + return fmt.Errorf("secret source %q: %w", source.Name, err) + } + return nil +} + +func validateProjectSecrets(values []string, sources map[string]struct{}) error { + for _, value := range values { + if err := validateProjectSecret(value, sources); err != nil { + return err + } + } + return nil +} + +func validateProjectSecret(value string, sources map[string]struct{}) error { + ref, err := ParseRef(value) + if err != nil { + return fmt.Errorf("project secret %q: %w", value, err) + } + if ref.Source == LocalSourceName { + return fmt.Errorf("project configuration may not attach local secret %q", ref.Name) + } + if _, ok := sources[ref.Source]; !ok { + return fmt.Errorf("project secret %q references undefined source %q", value, ref.Source) + } + return nil +} + +// CleanProjectSourcePath validates a repository-controlled source path and +// returns a normalized repository-relative slash path. Repository config must +// never be able to read arbitrary host files. +func CleanProjectSourcePath(value string) (string, error) { + value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + if value == "" { + return "", fmt.Errorf("source path must not be empty") + } + if strings.HasPrefix(value, "/") { + return "", fmt.Errorf("source path %q must be relative to the repository root", value) + } + clean := path.Clean(value) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("source path %q escapes the repository root", value) + } + return strings.TrimPrefix(clean, "./"), nil +} + +// LoadProjectConfigFromRoot loads repository-owned config from a local checkout. +// A missing .devsy/config.yaml is not an error. +func LoadProjectConfigFromRoot(root string) (*ProjectConfig, bool, error) { + configPath := filepath.Join(root, filepath.FromSlash(ProjectConfigPath)) + // #nosec G304 -- configPath is intentionally rooted under the selected repository. + data, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, false, fmt.Errorf("read %s: %w", ProjectConfigPath, err) + } + cfg, err := ParseProjectConfig(data) + if err != nil { + return nil, false, err + } + return cfg, true, nil +} + +// ResolveProjectSourcePath converts a repository-controlled relative path to a +// local path while enforcing containment, including after symlink resolution. +func ResolveProjectSourcePath(root, value string) (string, error) { + candidate, resolvedRoot, err := projectSourceCandidate(root, value) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + return "", fmt.Errorf("resolve secret source path %q: %w", value, err) + } + if err := ensurePathContained(resolvedRoot, resolved, value); err != nil { + return "", err + } + if err := ensureRegularFile(resolved, value); err != nil { + return "", err + } + return resolved, nil +} + +func projectSourceCandidate(root, value string) (string, string, error) { + clean, err := CleanProjectSourcePath(value) + if err != nil { + return "", "", err + } + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", "", err + } + resolvedRoot, err := filepath.EvalSymlinks(rootAbs) + if err != nil { + return "", "", fmt.Errorf("resolve repository root: %w", err) + } + return filepath.Join(rootAbs, filepath.FromSlash(clean)), resolvedRoot, nil +} + +func ensurePathContained(root, candidate, original string) error { + rel, err := filepath.Rel(root, candidate) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("source path %q escapes the repository root", original) + } + return nil +} + +func ensureRegularFile(filePath, original string) error { + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("stat secret source path %q: %w", original, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("secret source path %q is not a regular file", original) + } + return nil +} diff --git a/pkg/secrets/project_config_test.go b/pkg/secrets/project_config_test.go new file mode 100644 index 000000000..d7eb2b8f4 --- /dev/null +++ b/pkg/secrets/project_config_test.go @@ -0,0 +1,56 @@ +package secrets + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseProjectConfig(t *testing.T) { + cfg, err := ParseProjectConfig([]byte(` +secretSources: + - name: project + type: sops + path: ./secrets.enc.yaml +secrets: + - sops:project/API_TOKEN +`)) + require.NoError(t, err) + require.Len(t, cfg.SecretSources, 1) + require.Equal(t, "project", cfg.SecretSources[0].Name) + require.Equal(t, []string{"sops:project/API_TOKEN"}, cfg.Secrets) +} + +func TestProjectConfigRejectsUndefinedSource(t *testing.T) { + _, err := ParseProjectConfig([]byte(`secrets: [sops:missing/API_TOKEN]`)) + require.ErrorContains(t, err, "undefined source") +} + +func TestCleanProjectSourcePath(t *testing.T) { + clean, err := CleanProjectSourcePath("./config/secrets.enc.yaml") + require.NoError(t, err) + require.Equal(t, "config/secrets.enc.yaml", clean) + + for _, bad := range []string{"", "/etc/passwd", "../secret", "a/../../secret"} { + _, err := CleanProjectSourcePath(bad) + require.Error(t, err, bad) + } +} + +func TestResolveProjectSourcePathRejectsSymlinkEscape(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation commonly requires elevated privileges on Windows") + } + root := t.TempDir() + outside := t.TempDir() + outsideSecret := filepath.Join(outside, "secret.yaml") + require.NoError(t, os.WriteFile(outsideSecret, []byte("secret"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "config"), 0o700)) + require.NoError(t, os.Symlink(outsideSecret, filepath.Join(root, "config", "secret.yaml"))) + + _, err := ResolveProjectSourcePath(root, "config/secret.yaml") + require.ErrorContains(t, err, "escapes the repository root") +} diff --git a/pkg/secrets/reference.go b/pkg/secrets/reference.go new file mode 100644 index 000000000..8963cb62c --- /dev/null +++ b/pkg/secrets/reference.go @@ -0,0 +1,79 @@ +package secrets + +import ( + "fmt" + "strings" +) + +const LocalSourceName = "local" + +const qualifiedRefFormat = "TYPE:SOURCE/NAME" + +// SecretRef identifies a named secret and the source instance that owns it. +// Unqualified references resolve from the local Devsy store. Qualified +// references use TYPE:SOURCE/NAME, for example sops:project/API_TOKEN. +type SecretRef struct { + Type string + Source string + Name string +} + +func ParseRef(value string) (SecretRef, error) { + value = strings.TrimSpace(value) + if value == "" { + return SecretRef{}, fmt.Errorf("invalid secret reference: value must not be empty") + } + if !strings.Contains(value, ":") { + return parseLocalRef(value) + } + return parseQualifiedRef(value) +} + +func parseLocalRef(value string) (SecretRef, error) { + if err := ValidateName(value); err != nil { + return SecretRef{}, err + } + return SecretRef{Type: LocalSourceName, Source: LocalSourceName, Name: value}, nil +} + +func parseQualifiedRef(value string) (SecretRef, error) { + typeName, rest, err := splitQualifiedType(value) + if err != nil { + return SecretRef{}, err + } + sourceName, name, err := splitQualifiedName(value, rest) + if err != nil { + return SecretRef{}, err + } + if err := ValidateSourceName(sourceName); err != nil { + return SecretRef{}, fmt.Errorf("invalid secret reference %q: %w", value, err) + } + return SecretRef{Type: typeName, Source: sourceName, Name: name}, nil +} + +func splitQualifiedType(value string) (string, string, error) { + typeName, rest, ok := strings.Cut(value, ":") + if !ok || typeName == "" || rest == "" { + return "", "", invalidQualifiedRef(value) + } + return typeName, rest, nil +} + +func splitQualifiedName(value, rest string) (string, string, error) { + sourceName, name, ok := strings.Cut(rest, "/") + if !ok || sourceName == "" || name == "" || strings.Contains(name, "/") { + return "", "", invalidQualifiedRef(value) + } + return sourceName, name, nil +} + +func invalidQualifiedRef(value string) error { + return fmt.Errorf("invalid secret reference %q: expected %s", value, qualifiedRefFormat) +} + +func (r SecretRef) String() string { + if r.Source == LocalSourceName && (r.Type == "" || r.Type == LocalSourceName) { + return r.Name + } + return r.Type + ":" + r.Source + "/" + r.Name +} diff --git a/pkg/secrets/reference_test.go b/pkg/secrets/reference_test.go new file mode 100644 index 000000000..b16723163 --- /dev/null +++ b/pkg/secrets/reference_test.go @@ -0,0 +1,48 @@ +package secrets + +import "testing" + +const ( + testAPISecret = "API_TOKEN" + testProjectSource = "project" +) + +func TestParseRef(t *testing.T) { + tests := []struct { + name string + input string + want SecretRef + }{ + { + name: "local", + input: testAPISecret, + want: SecretRef{Type: LocalSourceName, Source: LocalSourceName, Name: testAPISecret}, + }, + { + name: SOPSFormatter, + input: SOPSFormatter + ":" + testProjectSource + "/" + testAPISecret, + want: SecretRef{Type: SOPSFormatter, Source: testProjectSource, Name: testAPISecret}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseRef(tt.input) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("ParseRef(%q) = %#v, want %#v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseRefRejectsMalformedQualifiedRefs(t *testing.T) { + for _, value := range []string{"", "sops:", "sops:project", "sops:/TOKEN", "sops:project/", "sops:project/a/b"} { + t.Run(value, func(t *testing.T) { + if _, err := ParseRef(value); err == nil { + t.Fatalf("ParseRef(%q) unexpectedly succeeded", value) + } + }) + } +} diff --git a/pkg/secrets/resolver.go b/pkg/secrets/resolver.go new file mode 100644 index 000000000..2d1664b1b --- /dev/null +++ b/pkg/secrets/resolver.go @@ -0,0 +1,81 @@ +package secrets + +import ( + "context" + "fmt" + "regexp" +) + +var sourceNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) + +type registeredSource struct { + typeName string + source Source +} + +// Resolver routes a SecretRef to an explicitly registered source instance. +type Resolver struct { + sources map[string]registeredSource +} + +func NewResolver() *Resolver { + return &Resolver{sources: map[string]registeredSource{}} +} + +func ValidateSourceName(name string) error { + if !sourceNamePattern.MatchString(name) { + return fmt.Errorf( + "invalid secret source name %q: must start with a letter and contain only letters, digits, underscores, and hyphens", + name, + ) + } + return nil +} + +func (r *Resolver) Register(name, typeName string, source Source) error { + if err := ValidateSourceName(name); err != nil { + return err + } + if source == nil { + return fmt.Errorf("secret source %q is nil", name) + } + if _, exists := r.sources[name]; exists { + return fmt.Errorf("secret source %q already exists", name) + } + if name == LocalSourceName && typeName != LocalSourceName { + return fmt.Errorf("secret source name %q is reserved", LocalSourceName) + } + r.sources[name] = registeredSource{typeName: typeName, source: source} + return nil +} + +func (r *Resolver) Resolve(ctx context.Context, ref SecretRef) (ResolvedSecret, error) { + registered, ok := r.sources[ref.Source] + if !ok { + return ResolvedSecret{}, fmt.Errorf("secret source %q is not configured", ref.Source) + } + if ref.Type != "" && registered.typeName != "" && ref.Type != registered.typeName { + return ResolvedSecret{}, fmt.Errorf( + "secret source %q is type %q, not %q", + ref.Source, + registered.typeName, + ref.Type, + ) + } + resolved, err := registered.source.Get(ctx, ref.Name) + if err != nil { + return ResolvedSecret{}, fmt.Errorf( + "resolve secret %q from source %q: %w", + ref.Name, + ref.Source, + err, + ) + } + if resolved.Source == "" { + resolved.Source = ref.Source + } + if resolved.Name == "" { + resolved.Name = ref.Name + } + return resolved, nil +} diff --git a/pkg/secrets/resolver_test.go b/pkg/secrets/resolver_test.go new file mode 100644 index 000000000..46aec6b67 --- /dev/null +++ b/pkg/secrets/resolver_test.go @@ -0,0 +1,74 @@ +package secrets + +import ( + "context" + "errors" + "testing" +) + +const testResolverToken = "TOKEN" + +type testSource struct { + value string + err error + calls int +} + +func (s *testSource) Get(_ context.Context, name string) (ResolvedSecret, error) { + s.calls++ + if s.err != nil { + return ResolvedSecret{}, s.err + } + return ResolvedSecret{Name: name, Value: s.value, Sensitive: true}, nil +} + +func TestResolverRoutesExplicitSource(t *testing.T) { + r := NewResolver() + s := &testSource{value: "secret"} + if err := r.Register(testProjectSource, SOPSFormatter, s); err != nil { + t.Fatal(err) + } + got, err := r.Resolve( + context.Background(), + SecretRef{Type: SOPSFormatter, Source: testProjectSource, Name: testResolverToken}, + ) + if err != nil { + t.Fatal(err) + } + if got.Value != "secret" || got.Source != testProjectSource || got.Name != testResolverToken { + t.Fatalf("unexpected resolved secret: %#v", got) + } +} + +func TestResolverDoesNotFallback(t *testing.T) { + r := NewResolver() + local := &testSource{value: LocalSourceName} + if err := r.Register(LocalSourceName, LocalSourceName, local); err != nil { + t.Fatal(err) + } + _, err := r.Resolve( + context.Background(), + SecretRef{Type: SOPSFormatter, Source: "missing", Name: testResolverToken}, + ) + if err == nil { + t.Fatal("expected missing source error") + } + if local.calls != 0 { + t.Fatalf("local source was called %d times", local.calls) + } +} + +func TestResolverWrapsSourceError(t *testing.T) { + r := NewResolver() + s := &testSource{err: ErrSecretNotFound} + if err := r.Register(testProjectSource, SOPSFormatter, s); err != nil { + t.Fatal(err) + } + _, err := r.Resolve( + context.Background(), + SecretRef{Type: SOPSFormatter, Source: testProjectSource, Name: testResolverToken}, + ) + if !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("expected ErrSecretNotFound, got %v", err) + } +} diff --git a/pkg/secrets/sops_source.go b/pkg/secrets/sops_source.go new file mode 100644 index 000000000..7586ebb68 --- /dev/null +++ b/pkg/secrets/sops_source.go @@ -0,0 +1,211 @@ +package secrets + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/getsops/sops/v3/decrypt" + "github.com/joho/godotenv" + "gopkg.in/yaml.v3" +) + +const ( + SOPSFormatter = "sops" + SOPSFormatYAML = "yaml" + SOPSFormatJSON = "json" + SOPSFormatDotenv = "dotenv" +) + +// SOPSSource resolves values from one SOPS-encrypted document. A source is +// command-scoped: decrypted values are cached in memory for the lifetime of the +// source instance and are never persisted by Devsy. +type SOPSSource struct { + name string + path string + format string + encrypted []byte + + once sync.Once + data map[string]string + err error +} + +func NewSOPSSource(name, filePath, format string) *SOPSSource { + return &SOPSSource{name: name, path: filePath, format: format} +} + +// NewSOPSDataSource is used for repository inspection where the encrypted file +// is read directly from a Git revision without being materialized on disk. +func NewSOPSDataSource(name, logicalPath, format string, encrypted []byte) *SOPSSource { + return &SOPSSource{ + name: name, + path: logicalPath, + format: format, + encrypted: append([]byte(nil), encrypted...), + } +} + +func (s *SOPSSource) Get(ctx context.Context, name string) (ResolvedSecret, error) { + if err := ctx.Err(); err != nil { + return ResolvedSecret{}, err + } + if s == nil { + return ResolvedSecret{}, fmt.Errorf("SOPS secret source is nil") + } + s.once.Do(func() { s.data, s.err = s.load(ctx) }) + if s.err != nil { + return ResolvedSecret{}, s.err + } + value, ok := s.data[name] + if !ok { + return ResolvedSecret{}, fmt.Errorf( + "secret %q was not found in SOPS source %q", + name, + s.name, + ) + } + return ResolvedSecret{Name: name, Value: value, Sensitive: true, Source: s.name}, nil +} + +// Validate forces decryption and document validation without exposing values. +func (s *SOPSSource) Validate(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + s.once.Do(func() { s.data, s.err = s.load(ctx) }) + return s.err +} + +func (s *SOPSSource) load(ctx context.Context) (map[string]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + format, err := normalizeSOPSFormat(s.format, s.path) + if err != nil { + return nil, err + } + plaintext, err := s.decrypt(format) + if err != nil { + return nil, err + } + values, err := parseSOPSDocument(plaintext, format) + if err != nil { + return nil, fmt.Errorf("failed to load SOPS source %q: %w", s.name, err) + } + return values, nil +} + +func (s *SOPSSource) decrypt(format string) ([]byte, error) { + var ( + plaintext []byte + err error + ) + if s.encrypted != nil { + plaintext, err = decrypt.Data(s.encrypted, format) + } else { + if s.path == "" { + return nil, fmt.Errorf("SOPS source %q has no path", s.name) + } + if _, statErr := os.Stat(s.path); statErr != nil { + return nil, fmt.Errorf("read SOPS source %q: %w", s.name, statErr) + } + plaintext, err = decrypt.File(s.path, format) + } + if err != nil { + // Do not include encrypted/decrypted document contents in the error. The + // upstream error is retained because it contains actionable key-provider + // information (for example, an unmatched age recipient or KMS failure). + return nil, fmt.Errorf("failed to decrypt SOPS source %q: %w", s.name, err) + } + return plaintext, nil +} + +func normalizeSOPSFormat(explicit, filePath string) (string, error) { + format := strings.ToLower(strings.TrimSpace(explicit)) + if format == "" { + return inferSOPSFormat(filePath) + } + return canonicalSOPSFormat(format) +} + +func inferSOPSFormat(filePath string) (string, error) { + switch strings.ToLower(filepath.Ext(filePath)) { + case ".yaml", ".yml": + return SOPSFormatYAML, nil + case ".json": + return SOPSFormatJSON, nil + case ".env": + return SOPSFormatDotenv, nil + default: + return "", fmt.Errorf( + "cannot determine SOPS format for %q; set format to yaml, json, or dotenv", + filePath, + ) + } +} + +func canonicalSOPSFormat(format string) (string, error) { + switch format { + case SOPSFormatYAML, SOPSFormatJSON, SOPSFormatDotenv: + return format, nil + case "env": + return SOPSFormatDotenv, nil + default: + return "", fmt.Errorf( + "unsupported SOPS format %q; expected yaml, json, or dotenv", + format, + ) + } +} + +func parseSOPSDocument(plaintext []byte, format string) (map[string]string, error) { + if format == SOPSFormatDotenv { + values, err := godotenv.Unmarshal(string(plaintext)) + if err != nil { + return nil, fmt.Errorf("parse dotenv document: %w", err) + } + return values, nil + } + + var raw map[string]any + var err error + if format == SOPSFormatJSON { + err = json.Unmarshal(plaintext, &raw) + } else { + err = yaml.Unmarshal(plaintext, &raw) + } + if err != nil { + return nil, fmt.Errorf("parse %s document: %w", format, err) + } + if raw == nil { + return map[string]string{}, nil + } + + out := make(map[string]string, len(raw)) + for key, value := range raw { + scalar, err := stringifySecretScalar(value) + if err != nil { + return nil, fmt.Errorf("secret %q: %w", key, err) + } + out[key] = scalar + } + return out, nil +} + +func stringifySecretScalar(value any) (string, error) { + switch v := value.(type) { + case string: + return v, nil + case bool, int, int64, uint64, float64: + return fmt.Sprint(v), nil + case nil: + return "", fmt.Errorf("value is null; only scalar non-null values are supported") + default: + return "", fmt.Errorf("value is not a scalar; nested objects and arrays are not supported") + } +} diff --git a/pkg/secrets/sops_source_test.go b/pkg/secrets/sops_source_test.go new file mode 100644 index 000000000..23dac962c --- /dev/null +++ b/pkg/secrets/sops_source_test.go @@ -0,0 +1,114 @@ +package secrets + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + testSOPSPlaintextMarker = "SUPER_SECRET_TEST_VALUE_7B91" + testSOPSFixturePath = "testdata/sops-age.yaml" + // Synthetic test-only identity generated solely for this fixture. gitleaks:allow. + testSOPSAgeIdentity = "AGE-SECRET-KEY-12UWYSAH2MRDQ5K4EWC4253PDTCSCS32Y5EFQ8TEN2SL3QYU2GN2SG88CZX" +) + +func encryptedSOPSTestFixture(t *testing.T) []byte { + t.Helper() + data, err := os.ReadFile(testSOPSFixturePath) + require.NoError(t, err) + return data +} + +func TestSOPSSourceDecryptsAgeFixture(t *testing.T) { + t.Setenv("SOPS_AGE_KEY", testSOPSAgeIdentity) + source := NewSOPSDataSource( + "test", + "secrets.enc.yaml", + SOPSFormatYAML, + encryptedSOPSTestFixture(t), + ) + + secret, err := source.Get(context.Background(), "SOPS_E2E_SECRET") + require.NoError(t, err) + require.Equal(t, testSOPSPlaintextMarker, secret.Value) + require.True(t, secret.Sensitive) + + mounted, err := source.Get(context.Background(), "TLS_KEY") + require.NoError(t, err) + require.Equal(t, "mounted-value-77", mounted.Value) +} + +func TestSOPSSourceDecryptFailureDoesNotLeakPlaintext(t *testing.T) { + t.Setenv("SOPS_AGE_KEY", "") + t.Setenv("SOPS_AGE_KEY_FILE", t.TempDir()+"/missing") + source := NewSOPSDataSource( + "test", + "secrets.enc.yaml", + SOPSFormatYAML, + encryptedSOPSTestFixture(t), + ) + + err := source.Validate(context.Background()) + require.Error(t, err) + require.NotContains(t, err.Error(), testSOPSPlaintextMarker) + require.NotContains(t, strings.ToLower(err.Error()), "mounted-value-77") +} + +func TestParseSOPSDocumentYAML(t *testing.T) { + values, err := parseSOPSDocument( + []byte("API_TOKEN: secret\nPORT: 5432\nDEBUG: false\n"), + SOPSFormatYAML, + ) + require.NoError(t, err) + require.Equal(t, "secret", values["API_TOKEN"]) + require.Equal(t, "5432", values["PORT"]) + require.Equal(t, "false", values["DEBUG"]) +} + +func TestParseSOPSDocumentJSON(t *testing.T) { + values, err := parseSOPSDocument( + []byte(`{"API_TOKEN":"secret","PORT":5432}`), + SOPSFormatJSON, + ) + require.NoError(t, err) + require.Equal(t, "secret", values["API_TOKEN"]) + require.Equal(t, "5432", values["PORT"]) +} + +func TestParseSOPSDocumentDotenv(t *testing.T) { + values, err := parseSOPSDocument( + []byte("API_TOKEN=secret\nPORT=5432\n"), + SOPSFormatDotenv, + ) + require.NoError(t, err) + require.Equal(t, "secret", values["API_TOKEN"]) + require.Equal(t, "5432", values["PORT"]) +} + +func TestParseSOPSDocumentRejectsNestedValue(t *testing.T) { + _, err := parseSOPSDocument( + []byte("database:\n password: secret\n"), + SOPSFormatYAML, + ) + require.ErrorContains(t, err, "not a scalar") +} + +func TestNormalizeSOPSFormat(t *testing.T) { + for _, tc := range []struct { + path string + want string + }{ + {"secrets.enc.yaml", SOPSFormatYAML}, + {"secrets.enc.yml", SOPSFormatYAML}, + {"secrets.enc.json", SOPSFormatJSON}, + {"secrets.enc.env", SOPSFormatDotenv}, + } { + got, err := normalizeSOPSFormat("", tc.path) + require.NoError(t, err) + require.Equal(t, tc.want, got) + } +} diff --git a/pkg/secrets/source.go b/pkg/secrets/source.go new file mode 100644 index 000000000..dbe82b402 --- /dev/null +++ b/pkg/secrets/source.go @@ -0,0 +1,17 @@ +package secrets + +import "context" + +// ResolvedSecret is the runtime value returned by a secret source. +type ResolvedSecret struct { + Name string + Value string + Sensitive bool + Source string +} + +// Source resolves externally or locally owned secret values. Implementations +// must not persist values as a side effect of Get. +type Source interface { + Get(ctx context.Context, name string) (ResolvedSecret, error) +} diff --git a/pkg/secrets/source_config.go b/pkg/secrets/source_config.go new file mode 100644 index 000000000..2becf0203 --- /dev/null +++ b/pkg/secrets/source_config.go @@ -0,0 +1,148 @@ +package secrets + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/devsy-org/devsy/pkg/config" + "sigs.k8s.io/yaml" +) + +const sourceConfigFileName = "secret-sources.yaml" + +// SourceConfig describes an external secret source. It contains references +// only; secret values and decryption credentials are never persisted here. +type SourceConfig struct { + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` +} + +type sourceRegistryFile struct { + Contexts map[string][]SourceConfig `json:"contexts,omitempty" yaml:"contexts,omitempty"` +} + +func sourceConfigPath(devsyConfig *config.Config) (string, error) { + if devsyConfig != nil && devsyConfig.Origin != "" { + return filepath.Join(filepath.Dir(devsyConfig.Origin), sourceConfigFileName), nil + } + configPath, err := config.GetConfigPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(configPath), sourceConfigFileName), nil +} + +// LoadSourceConfigs loads external sources registered for the active context. +func LoadSourceConfigs(devsyConfig *config.Config) ([]SourceConfig, error) { + filePath, err := sourceConfigPath(devsyConfig) + if err != nil { + return nil, err + } + registry, err := loadSourceRegistry(filePath) + if err != nil { + return nil, err + } + if registry.Contexts == nil || devsyConfig == nil { + return nil, nil + } + items := registry.Contexts[devsyConfig.DefaultContext] + return append([]SourceConfig(nil), items...), nil +} + +// SaveSourceConfigs writes external source metadata for the active context. +func SaveSourceConfigs(devsyConfig *config.Config, sources []SourceConfig) error { + if devsyConfig == nil { + return fmt.Errorf("devsy config is nil") + } + filePath, err := sourceConfigPath(devsyConfig) + if err != nil { + return err + } + registry, err := loadSourceRegistry(filePath) + if err != nil { + return err + } + setContextSources(®istry, devsyConfig.DefaultContext, sources) + return writeSourceRegistry(filePath, registry) +} + +func loadSourceRegistry(filePath string) (sourceRegistryFile, error) { + registry := sourceRegistryFile{Contexts: map[string][]SourceConfig{}} + // #nosec G304 -- filePath is generated by sourceConfigPath under Devsy's config directory. + data, err := os.ReadFile(filePath) + if os.IsNotExist(err) { + return registry, nil + } + if err != nil { + return registry, fmt.Errorf("read secret source config: %w", err) + } + if err := yaml.Unmarshal(data, ®istry); err != nil { + return registry, fmt.Errorf("parse secret source config: %w", err) + } + if registry.Contexts == nil { + registry.Contexts = map[string][]SourceConfig{} + } + return registry, nil +} + +func setContextSources(registry *sourceRegistryFile, contextName string, sources []SourceConfig) { + if registry.Contexts == nil { + registry.Contexts = map[string][]SourceConfig{} + } + if len(sources) == 0 { + delete(registry.Contexts, contextName) + return + } + registry.Contexts[contextName] = append([]SourceConfig(nil), sources...) +} + +func writeSourceRegistry(filePath string, registry sourceRegistryFile) error { + data, err := yaml.Marshal(registry) + if err != nil { + return fmt.Errorf("marshal secret source config: %w", err) + } + if err := os.MkdirAll(filepath.Dir(filePath), 0o700); err != nil { + return err + } + if err := os.WriteFile(filePath, data, 0o600); err != nil { + return fmt.Errorf("write secret source config: %w", err) + } + return nil +} + +func FindSourceConfig(sources []SourceConfig, name string) (SourceConfig, bool) { + for _, source := range sources { + if source.Name == name { + return source, true + } + } + return SourceConfig{}, false +} + +func AddSourceConfig(sources []SourceConfig, source SourceConfig) ([]SourceConfig, error) { + if err := ValidateSourceName(source.Name); err != nil { + return nil, err + } + if source.Name == LocalSourceName { + return nil, fmt.Errorf("secret source name %q is reserved", LocalSourceName) + } + if _, exists := FindSourceConfig(sources, source.Name); exists { + return nil, fmt.Errorf("secret source %q already exists", source.Name) + } + return append(append([]SourceConfig(nil), sources...), source), nil +} + +func RemoveSourceConfig(sources []SourceConfig, name string) ([]SourceConfig, bool) { + for i, source := range sources { + if source.Name != name { + continue + } + out := append([]SourceConfig(nil), sources[:i]...) + out = append(out, sources[i+1:]...) + return out, true + } + return append([]SourceConfig(nil), sources...), false +} diff --git a/pkg/secrets/source_factory.go b/pkg/secrets/source_factory.go new file mode 100644 index 000000000..6e611ea9d --- /dev/null +++ b/pkg/secrets/source_factory.go @@ -0,0 +1,57 @@ +package secrets + +import ( + "fmt" + + "github.com/devsy-org/devsy/pkg/config" +) + +// NewResolverForConfig constructs the local Devsy source plus all external +// sources registered in the active local context. Sources decrypt lazily. +func NewResolverForConfig(devsyConfig *config.Config) (*Resolver, error) { + if devsyConfig == nil { + return nil, fmt.Errorf("devsy config is nil") + } + store, err := NewStoreForConfig(devsyConfig) + if err != nil { + return nil, err + } + resolver := NewResolver() + if err := resolver.Register( + LocalSourceName, + LocalSourceName, + NewLocalSource(store, devsyConfig.DefaultContext), + ); err != nil { + return nil, err + } + configs, err := LoadSourceConfigs(devsyConfig) + if err != nil { + return nil, err + } + for _, sourceConfig := range configs { + if err := RegisterConfiguredSource(resolver, sourceConfig); err != nil { + return nil, err + } + } + return resolver, nil +} + +func RegisterConfiguredSource(resolver *Resolver, sourceConfig SourceConfig) error { + if resolver == nil { + return fmt.Errorf("secret resolver is nil") + } + switch sourceConfig.Type { + case SOPSFormatter: + return resolver.Register( + sourceConfig.Name, + sourceConfig.Type, + NewSOPSSource(sourceConfig.Name, sourceConfig.Path, sourceConfig.Format), + ) + default: + return fmt.Errorf( + "secret source %q has unsupported type %q", + sourceConfig.Name, + sourceConfig.Type, + ) + } +} diff --git a/pkg/secrets/testdata/sops-age.yaml b/pkg/secrets/testdata/sops-age.yaml new file mode 100644 index 000000000..79a5fdfd8 --- /dev/null +++ b/pkg/secrets/testdata/sops-age.yaml @@ -0,0 +1,17 @@ +SOPS_E2E_SECRET: ENC[AES256_GCM,data:7/LkTGbUbM24m2YegXZhCPwFErYDhnRC3jflQA==,iv:jox9Lg827lNean0A3dbFnjbizHq/+e5Oj41ntzFKtNc=,tag:H0XcdWPIYBmte+v0F9j47Q==,type:str] +TLS_KEY: ENC[AES256_GCM,data:7qUD3Paq3RT0XbF/qvSnfw==,iv:ClkYTnncQRPn3JhDZ9iZfuY0lNDYY71ot6HRjF2HCVY=,tag:oGoczTS56kPXL9N4/FY+nA==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwWHBIUkxaRWlsdFRidWFF + OHdHTHZGZUh2UGRzOXhVdFlxRnZIZkY3eTFnCnpoTFNrNVZKcFRtTVhjbnBUK1c1 + SGFnd2R0ZGQvOHFueWpHR0xiOGVvdTAKLS0tIDJTSVFCazNueGJkMlZDUU9vK21j + d0xPTEV4NVZGL1EweHBqM3lTSG9sb3MKJtSnyqRMtWM2WRIEJSHQPkYtBXa+wSpG + PXamDLnUGIrzsjbl6uTZxHytVSwfVERwWzagJTEOajIJpFwHIyGD/A== + -----END AGE ENCRYPTED FILE----- + recipient: age1h84qv0p8kdwfp6vrag4nvmpd6gt3mde9g6dcnj9uryh3qcl50uxq83n932 + lastmodified: "2026-09-03T05:38:40Z" + mac: ENC[AES256_GCM,data:5thaKGP8s7dbN/9VvRn54TB0R6UaRlRvtyoby0yn5gGlCAYZLvBlAXmTY8AQ0ShQII3ictP2KpXVLGd0dp0H0LXGr9PpsdXIVEO5t/WS5YO+ZhdxZ5hgWBzEL1rZ2VO9Bt0DPFmEYNNryqIpLU64lbd+AO5P/6/uaBMZZERYE0Y=,iv:7nUHdzpyZidt2DRQdvSv2W2GCF5JArFzvyX9ppVEP5k=,tag:/KTQAguMfRBpWPDumiTunA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.3 diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 21727d307..93339dfd6 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -3,175 +3,388 @@ title: Secrets in a Workspace sidebar_label: Secrets --- -## Storing Secrets +Devsy can inject sensitive values into workspaces without placing plaintext +credentials in `devcontainer.json`, command-line arguments, or ordinary dotenv +files. -Devsy can store named secrets locally and inject them into your workspace's -lifecycle commands as environment variables. This lets you keep credentials out -of your `devcontainer.json`, shell history, and dotenv files. +Secrets can come from two places: -Secret **values** are stored using the selected backend. With the default -`auto` backend, Devsy uses your operating system's keyring when available: +- **Devsy-managed secrets** — values owned by Devsy and stored in the operating + system keyring or Devsy's encrypted local file. +- **External secret sources** — values owned by another system, such as a + SOPS-encrypted file. Devsy resolves these values when they are needed and does + not import them into the local Devsy secret store. + +Both use the same protected workspace delivery paths: lifecycle environment +variables, in-memory files under `/run/secrets`, and supported build-secret +mechanisms. + +## Devsy-Managed Secrets + +With the default `auto` storage backend, Devsy stores sensitive values in your +operating system's keyring when one is available: - **macOS** — Keychain - **Windows** — Credential Manager - **Linux** — Secret Service (libsecret / GNOME Keyring / KWallet) -When no keyring is available (or the `file` backend is selected), values are -kept in an age-encrypted file in the Devsy config directory instead. Either way, -only non-sensitive metadata (the secret's name and timestamps) is written in -plaintext. Secrets are scoped to the active [context](../managing-providers/what-are-providers). +When no keyring is available, or when the `file` backend is selected, Devsy +stores values in an age-encrypted `secrets.enc` file in the Devsy config +directory. Only non-sensitive metadata such as names and timestamps is written +in plaintext. Devsy-managed secrets are scoped to the active +[context](../managing-providers/what-are-providers). ### Creating a Secret -``` +```shell +# Interactive input; the value is not echoed. devsy secret set DB_PASSWORD + +# Standard input is recommended for scripts. +printf '%s' "$MY_VALUE" | devsy secret set DB_PASSWORD --stdin + +# Or read a value from a file. +devsy secret set TLS_KEY --from-file ./tls.key ``` -You will be prompted for the value without echoing it to the terminal. You can -also supply the value non-interactively: +### Listing, Reading, and Deleting +```shell +devsy secret list +devsy secret get DB_PASSWORD +devsy secret delete DB_PASSWORD ``` -# From standard input (recommended for scripts) -printf '%s' "$MY_VALUE" | devsy secret set DB_PASSWORD --stdin -# From a file -devsy secret set TLS_KEY --from-file ./tls.key +`devsy secret list` never prints values. A value removed from the OS keyring +outside Devsy is shown as `orphaned`. + +## External Secret Sources + +An external secret source lets Devsy resolve a value at workspace startup +without making Devsy a second source of truth for that value. + +### SOPS + +Devsy supports [SOPS](https://github.com/getsops/sops)-encrypted YAML, JSON, and +dotenv files. Devsy uses the SOPS Go implementation directly; installing a +`sops` executable is not required for workspace startup. + +SOPS source documents use flat top-level key/value pairs in the initial +implementation: + +```yaml +DATABASE_PASSWORD: ENC[...] +API_TOKEN: ENC[...] ``` -Secret names may contain only letters, digits, and underscores, since they are -used directly as environment-variable names. +Nested objects and arrays are not implicitly flattened into secret names. + +### Registering a Local SOPS Source -### Listing and Reading Secrets +If the encrypted file is already available on the machine running Devsy, add a +named source: +```shell +devsy secret source add sops project ./secrets.enc.yaml ``` -devsy secret list -devsy secret get DB_PASSWORD + +Devsy validates that the file can be decrypted before saving the source. The +configuration stores the source name, type, and file path only. Decrypted +values are not copied into the keyring or Devsy's `secrets.enc` file. + +List or remove locally registered sources with: + +```shell +devsy secret source list +devsy secret source remove project ``` -`list` never prints values. A secret whose value has been removed from the -keyring out-of-band is shown with the `orphaned` status. +A source cannot be removed while context-level secret bindings still reference +it. + +### Repository-Owned SOPS Sources -### Deleting a Secret +A repository can declare SOPS sources in `.devsy/config.yaml` so every developer +uses the repository's encrypted file as the source of truth. +```text +project/ +├── .devcontainer/ +├── .devsy/ +│ └── config.yaml +└── secrets.enc.yaml ``` -devsy secret delete DB_PASSWORD + +Example `.devsy/config.yaml`: + +```yaml +secretSources: + - name: project + type: sops + path: ./secrets.enc.yaml + +secrets: + - sops:project/DATABASE_PASSWORD ``` -## Using Secrets in a Workspace +Repository source paths are resolved relative to the repository root. Devsy +rejects paths, including symlink targets, that escape the repository root. -### Per Workspace +Repository-owned sources work with a local checkout: -Reference a stored secret by name when bringing up a workspace with `--secret` -(repeatable). By default the secret is injected as an environment variable into -lifecycle commands: +```shell +devsy workspace up . +``` + +and with a remote Git source: +```shell +devsy workspace up https://github.com/acme/project ``` -devsy workspace up https://github.com/example/repo --secret DB_PASSWORD + +For a remote source Devsy first acquires enough repository data to read the +exact requested Git revision, then reads `.devsy/config.yaml` and the referenced +encrypted SOPS files from that same revision. Runtime and build secrets are +resolved only after this repository discovery step. + +### Bootstrap Authentication for Private Repositories + +Credentials needed to acquire a private repository must be available **before** +Devsy can inspect repository-owned SOPS files. + +For example, this is valid: + +```text +local Devsy secret / Git credential / SSH agent + │ + ▼ + authenticate repository clone + │ + ▼ + repository SOPS runtime secrets ``` -Each `--secret` accepts options as `NAME[,type=env|mount][,target=X]`: +A repository-owned SOPS secret cannot authenticate the clone of the same +repository that contains it. That would create a dependency cycle: Devsy would +need the repository to read the token and the token to read the repository. +Devsy fails this configuration rather than retrying or falling back to another +secret source. + +`--git-token` may reference a Devsy-managed secret or another external source +that is already locally available before repository acquisition. + +## Referencing Secrets -- `type=env` (default) sets an environment variable; `target` overrides the - variable name. -- `type=mount` writes the value to a file at `/run/secrets/` on an - in-memory filesystem, matching the Docker/Podman convention. `target` - defaults to `NAME`. On Kubernetes this uses an in-memory `emptyDir`; providers - that cannot mount an in-memory filesystem reject `type=mount` with a clear - error, so use `type=env` there. +An unqualified name continues to mean a Devsy-managed local secret: +```text +DB_PASSWORD ``` -# Inject under a different environment-variable name -devsy workspace up ... --secret DB_PASSWORD,target=DATABASE_PASSWORD -# Mount as a file at /run/secrets/tls.key -devsy workspace up ... --secret TLS_KEY,type=mount,target=tls.key +An external source uses a source-qualified reference: + +```text +sops:project/DB_PASSWORD ``` -### For all Workspaces +Devsy does not search other sources if a reference cannot be resolved. Explicit +source selection prevents one source from silently shadowing another. + +### Lifecycle Environment Variables -Bind a secret to the active context so it is injected automatically on every -`up`, without repeating `--secret`: +`--secret` is repeatable. By default, Devsy exposes the requested value as an +environment variable to lifecycle commands: +```shell +devsy workspace up . --secret DB_PASSWORD +devsy workspace up . --secret sops:project/DATABASE_PASSWORD ``` + +Use `target=` to change the environment-variable name: + +```shell +devsy workspace up . \ + --secret sops:project/DATABASE_PASSWORD,target=DB_PASSWORD +``` + +### Mounted Secret Files + +Use `type=mount` to write the secret to the existing in-memory secret mount: + +```shell +devsy workspace up . \ + --secret sops:project/TLS_KEY,type=mount,target=tls.key +``` + +The value is available at: + +```text +/run/secrets/tls.key +``` + +The same provider-specific memory-backed mechanism used for Devsy-managed +secrets is used for external sources. Providers that cannot offer an in-memory +secret mount reject `type=mount` with an error. + +### Build Secrets + +Source-qualified references can also be used with Devsy's existing build-secret +path: + +```shell +devsy workspace up . --build-secret sops:project/NPM_TOKEN +``` + +The build secret ID is the secret key (`NPM_TOKEN`), not the complete +source-qualified reference. Builds continue to consume it through the existing +BuildKit secret mechanism, for example: + +```dockerfile +RUN --mount=type=secret,id=NPM_TOKEN ... +``` + +### Attaching a Secret to a Context + +Attach a locally available secret reference to the active context so it is +injected on every `workspace up`: + +```shell devsy secret attach DB_PASSWORD +devsy secret attach sops:project/API_TOKEN +``` + +Detach it with: + +```shell devsy secret detach DB_PASSWORD +devsy secret detach sops:project/API_TOKEN +``` + +Devsy stores only the source-qualified reference for an external secret. It +does not copy the value into the local Devsy store. + +Repository-owned `.devsy/config.yaml` can declare project-specific automatic +bindings with its `secrets` list, which avoids requiring each developer to +attach the same project secrets manually. + +If any requested or attached value cannot be resolved, `workspace up` fails +rather than silently starting without it. + +## SOPS Credential Discovery + +Devsy delegates key and KMS credential discovery to SOPS instead of creating a +parallel credential system. + +For age-encrypted files, normal SOPS mechanisms apply, including +`SOPS_AGE_KEY`, `SOPS_AGE_KEY_FILE`, and the standard SOPS age-key location. +For example: + +```shell +export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" +devsy workspace up . ``` -Bound secret names are stored in your context configuration; deleting a context -also deletes its secrets from the keyring. +For AWS KMS, GCP KMS, Azure Key Vault, PGP, and other key services supported by +SOPS, use the same ambient credentials and key configuration you would use with +SOPS itself. + +Devsy does not automatically reuse the internal age identity used by its local +`file` storage backend. The Devsy storage key and a team's SOPS recipient key +belong to separate trust domains. + +## Git as the Source of Truth + +When a SOPS source is version-controlled, the encrypted file remains the +canonical persisted value. Updating and committing the encrypted file changes +what Devsy resolves the next time the workspace is started. There is no +`devsy secret set` import step to repeat after rotation. -If a requested or bound secret cannot be found when a workspace is created, the -`up` fails rather than silently continuing without it. +For remote Git workspaces, Devsy reads project configuration and encrypted +source documents from the same acquired revision so it does not combine secret +configuration from one commit with ciphertext from another. ## How Secrets Are Protected -Injected secret **values** are masked (`***`) in lifecycle-hook log output, so a -secret echoed by a `postCreateCommand` will not appear in the workspace logs. -Secret values are delivered to the workspace over Devsy's encrypted agent -tunnel, not embedded in any command line, so they are not exposed in process -listings on the host or inside the container. +Regardless of source, sensitive values use the existing Devsy secret-delivery +path. -Secrets delivered with `type=mount` are written to an in-memory `tmpfs` at -`/run/secrets`, so they are never persisted to a container image layer or to -disk. +- Lifecycle log output is redacted using the resolved secret values. +- Secret values are delivered through Devsy's protected agent transport rather + than embedded in lifecycle command lines. +- `type=mount` values use the in-memory `/run/secrets` mechanism and are not + baked into an image layer. +- SOPS plaintext is kept in command-scoped process memory; Devsy does not + intentionally persist the decrypted document or resolved values. +- SOPS age private keys, PGP private keys, and cloud KMS credentials remain + managed by SOPS and the user's environment rather than being imported into + the Devsy secret store. +- Missing sources, missing keys, invalid documents, and decryption failures fail + closed. -## Choosing a Storage Backend +Go-managed memory cannot provide a guarantee of immediate physical +zeroization. The guarantee is that Devsy does not intentionally create a +persistent plaintext copy of a resolved SOPS value. -Devsy supports two backends for secret values: +## SOPS Source vs. Devsy File Backend -- `keyring` — the OS keyring (Keychain / Credential Manager / libsecret). -- `file` — an [age](https://age-encryption.org)-encrypted file - (`secrets.enc`) stored alongside the Devsy config. +The two features both use encryption but serve different purposes. -By default the backend is `auto`: Devsy uses the keyring when one is available -and falls back to the encrypted file otherwise. +### Devsy `file` Backend -The `file` backend works with no configuration: Devsy generates and manages an -encryption key for you (stored in the OS keyring when available, otherwise in -`secrets.key` next to `secrets.enc`, mode `0600`). This protects secrets from -accidental disclosure and casual inspection, but a key stored in `secrets.key` -offers no protection against someone who can read your Devsy config directory -(they have both the key and the ciphertext). For stronger at-rest protection — -for example against a stolen backup — set a passphrase (see below), which is -never written to disk. +- Devsy owns the secret values. +- Values are stored in Devsy's configuration directory. +- Devsy encrypts its local store with age. +- Configure it through `SECRETS_BACKEND=file`. -Set a persistent preference per context with the `SECRETS_BACKEND` option: +### SOPS Source -``` +- The SOPS document owns the secret values. +- The encrypted document may live in Git. +- SOPS manages encryption metadata, recipients, and KMS integrations. +- Devsy decrypts requested values at use time. +- Values are not imported into Devsy's local store. + +Do not set `SECRETS_BACKEND=sops`; SOPS is a source, not a Devsy storage +backend. + +## Choosing a Devsy Storage Backend + +For Devsy-managed values, supported backends remain: + +- `keyring` — the OS keyring (Keychain / Credential Manager / libsecret). +- `file` — an [age](https://age-encryption.org)-encrypted `secrets.enc` stored + alongside the Devsy config. +- `auto` — use the keyring when available and otherwise fall back to `file`. + +Set a persistent preference per context with: + +```shell devsy context set -o SECRETS_BACKEND=file ``` -Allowed values are `auto`, `keyring`, and `file`. To override the preference for -a single command, set the `DEVSY_SECRETS_BACKEND` environment variable, which -takes precedence over the context option. +Or override it for one command with `DEVSY_SECRETS_BACKEND`. -## Headless and CI Environments +The local `file` backend generates and manages an encryption key automatically. +For stronger at-rest protection, set a passphrase with +`DEVSY_SECRETS_PASSPHRASE`; the derived encryption key is then not stored on +disk. -When the `file` backend is used (either by preference or because no OS keyring -is available, for example on headless Linux, containers, or CI), Devsy uses an -auto-generated key by default, so no setup is required. For at-rest protection -against a stolen backup or disk, set a passphrase — the encryption key is then -derived from it and never stored: +## Headless and CI Environments -``` -export DEVSY_SECRETS_PASSPHRASE="…" -devsy secret set DEPLOY_TOKEN --stdin < token.txt -``` +Devsy-managed `file` secrets continue to work in headless environments without +an OS keyring. SOPS sources additionally require whatever age/PGP/KMS +credentials the encrypted document itself requires. -The key source (auto-generated vs. passphrase) is recorded with your secrets. If -you later change it — for example by setting a passphrase for secrets that were -stored with an auto-generated key — Devsy reports a clear error rather than -failing to decrypt. Restore the original setting, or re-create the secrets under -the new one. +Keep those credentials in the CI platform's protected secret mechanism and +expose them using the standard SOPS discovery mechanism. Do not commit private +SOPS identities alongside the encrypted file. ## Managed Environment Variables -For non-sensitive configuration you can store managed environment variables with -`devsy env`. They share the same per-context store as secrets, but their values -are kept in plaintext in the Devsy config directory (no keyring, no encryption) -and are freely readable: +For non-sensitive configuration, `devsy env` stores managed environment +variables in plaintext Devsy configuration: -``` +```shell devsy env set LOG_LEVEL=debug devsy env set REGION --value us-east-1 devsy env list @@ -179,13 +392,12 @@ devsy env get LOG_LEVEL devsy env delete LOG_LEVEL ``` -Inject them into a workspace with `--env` (repeatable), optionally remapping the -variable name with `NAME=TARGET`: +Inject them with: -``` +```shell devsy workspace up ... --env LOG_LEVEL --env REGION=AWS_REGION ``` -Use `devsy secret` (not `devsy env`) for anything sensitive: secrets are stored -in the OS keyring or an encrypted file, masked in logs, and delivered over the -agent tunnel. +Use `devsy secret` or an external secret source for sensitive values. `--env` +is deliberately restricted to non-sensitive Devsy-managed values because that +path is not the protected secret-delivery channel. From 223027bcc2f844f2e9d3947067d7c1063a5df62d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 3 Sep 2026 21:56:15 +0000 Subject: [PATCH 02/17] fix(secrets): follow-up SOPS review cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 8cbdbe5286a088a15fe70c985a1e4f91cdf90911 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 3 Sep 2026 22:12:04 +0000 Subject: [PATCH 03/17] fix: resolve lint issues in SOPS review follow-up Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/workspace/up/up_client.go | 58 ++++++++++++++++++++++------------- cmd/workspace/up/up_test.go | 17 +++++----- pkg/git/config_test.go | 19 +++++++----- 3 files changed, 57 insertions(+), 37 deletions(-) diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index ff042d10b..436679f13 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -64,33 +64,15 @@ func (cmd *UpCmd) prepareClient( devsyConfig *config.Config, args []string, ) (client2.BaseWorkspaceClient, error) { - if err := mergeDevsyUpOptions(&cmd.CLIOptions); err != nil { - return nil, err - } - if cmd.Platform.Enabled { - log.Debug("running in platform mode") - log.Debug("using error output stream") - config.MergeContextOptions(devsyConfig.Current(), os.Environ()) - } - if err := cmd.validateFromSnapshot(ctx, args); err != nil { - return nil, err - } - source, err := cmd.parseWorkspaceSource() - if err != nil { + if err := cmd.prepareClientEnvironment(devsyConfig, args, ctx); err != nil { return nil, err } - // Bootstrap credentials are resolved exclusively from sources that are - // available before repository acquisition. Repository-owned sources are - // deliberately not registered yet, which prevents circular clone auth. - if err := cmd.prepareBootstrapGitToken(ctx, devsyConfig, source); err != nil { - return nil, err - } - projectSecrets, err := cmd.discoverProjectSecrets(ctx, source) + source, err := cmd.parseWorkspaceSource() if err != nil { return nil, err } - if err := cmd.prepareSecretsWithProject(ctx, devsyConfig, projectSecrets); err != nil { + if err := cmd.prepareWorkspaceSecrets(ctx, devsyConfig, source); err != nil { return nil, err } @@ -112,6 +94,40 @@ func (cmd *UpCmd) prepareClient( return client, nil } +func (cmd *UpCmd) prepareClientEnvironment( + devsyConfig *config.Config, + args []string, + ctx context.Context, +) error { + if err := mergeDevsyUpOptions(&cmd.CLIOptions); err != nil { + return err + } + if cmd.Platform.Enabled { + log.Debug("running in platform mode") + log.Debug("using error output stream") + config.MergeContextOptions(devsyConfig.Current(), os.Environ()) + } + return cmd.validateFromSnapshot(ctx, args) +} + +func (cmd *UpCmd) prepareWorkspaceSecrets( + ctx context.Context, + devsyConfig *config.Config, + source *provider2.WorkspaceSource, +) error { + // Bootstrap credentials are resolved exclusively from sources that are + // available before repository acquisition. Repository-owned sources are + // deliberately not registered yet, which prevents circular clone auth. + if err := cmd.prepareBootstrapGitToken(ctx, devsyConfig, source); err != nil { + return err + } + projectSecrets, err := cmd.discoverProjectSecrets(ctx, source) + if err != nil { + return err + } + return cmd.prepareSecretsWithProject(ctx, devsyConfig, projectSecrets) +} + // checkProviderUpdate checks for a provider update, unless running in platform mode. func (cmd *UpCmd) checkProviderUpdate( ctx context.Context, diff --git a/cmd/workspace/up/up_test.go b/cmd/workspace/up/up_test.go index ac0c83cde..3b59d6b70 100644 --- a/cmd/workspace/up/up_test.go +++ b/cmd/workspace/up/up_test.go @@ -18,11 +18,12 @@ import ( ) const ( - probeNone = "none" - flagNameMount = "mount" - flagMount = "--" + flagNameMount - testBindMountAB = "type=bind,source=/a,target=/b" - testSnapshotRef = "ghcr.io/acme/s:my-ws-20260731150405-abcxyz" + probeNone = "none" + flagNameMount = "mount" + flagMount = "--" + flagNameMount + testBindMountAB = "type=bind,source=/a,target=/b" + testSnapshotRef = "ghcr.io/acme/s:my-ws-20260731150405-abcxyz" + emptyIsValidName = "empty is valid" ) func TestUpCmd_NoLockfileAndFrozenLockfileMutuallyExclusive(t *testing.T) { @@ -42,7 +43,7 @@ func TestUpCmd_ValidateDefaultUserEnvProbe(t *testing.T) { value string wantErr bool }{ - {name: "empty is valid", value: "", wantErr: false}, + {name: emptyIsValidName, value: "", wantErr: false}, {name: "none", value: probeNone, wantErr: false}, {name: "loginShell", value: "loginShell", wantErr: false}, {name: "interactiveShell", value: "interactiveShell", wantErr: false}, @@ -119,7 +120,7 @@ func TestUpCmd_ValidateWorkspaceMountConsistency(t *testing.T) { value string wantErr bool }{ - {name: "empty is valid", value: "", wantErr: false}, + {name: emptyIsValidName, value: "", wantErr: false}, {name: "consistent", value: MountConsistencyConsistent, wantErr: false}, {name: "cached", value: MountConsistencyCached, wantErr: false}, {name: "delegated", value: MountConsistencyDelegated, wantErr: false}, @@ -265,7 +266,7 @@ func TestUpCmd_ValidateMounts(t *testing.T) { mounts []string wantErr bool }{ - {name: "empty is valid", mounts: []string{}}, + {name: emptyIsValidName, mounts: []string{}}, { name: "valid bind mount", mounts: []string{"type=bind,source=/host,target=/container"}, diff --git a/pkg/git/config_test.go b/pkg/git/config_test.go index 7682616c9..241150308 100644 --- a/pkg/git/config_test.go +++ b/pkg/git/config_test.go @@ -48,14 +48,17 @@ func TestConfigGetWithScope(t *testing.T) { fake.lastArgs()) } +// #nosec G101 -- not a credential value; it is a git config key name. +const credentialHelperKey = "credential.helper" + func TestConfigAddSystemScope(t *testing.T) { fake := &fakeRunner{} config := At("", WithRunner(fake)).Config() - err := config.Add(context.Background(), "credential.helper", "!helper", ScopeSystem) + err := config.Add(context.Background(), credentialHelperKey, "!helper", ScopeSystem) assert.NilError(t, err) assert.DeepEqual(t, - []string{subConfig, flagSystem, "--add", "credential.helper", "!helper"}, + []string{subConfig, flagSystem, "--add", credentialHelperKey, "!helper"}, fake.lastArgs()) } @@ -74,10 +77,10 @@ func TestConfigUnsetSystemScope(t *testing.T) { fake := &fakeRunner{} config := At("", WithRunner(fake)).Config() - err := config.Unset(context.Background(), "credential.helper", ScopeSystem) + err := config.Unset(context.Background(), credentialHelperKey, ScopeSystem) assert.NilError(t, err) assert.DeepEqual(t, - []string{subConfig, flagSystem, "--unset", "credential.helper"}, + []string{subConfig, flagSystem, "--unset", credentialHelperKey}, fake.lastArgs()) } @@ -85,7 +88,7 @@ func TestConfigUnsetValueScopesToExactValue(t *testing.T) { fake := &fakeRunner{} config := At("", WithRunner(fake)).Config() - err := config.UnsetValue(context.Background(), "credential.helper", "!my-helper", ScopeSystem) + err := config.UnsetValue(context.Background(), credentialHelperKey, "!my-helper", ScopeSystem) assert.NilError(t, err) assert.DeepEqual( t, @@ -94,7 +97,7 @@ func TestConfigUnsetValueScopesToExactValue(t *testing.T) { flagSystem, "--fixed-value", "--unset-all", - "credential.helper", + credentialHelperKey, "!my-helper", }, fake.lastArgs(), @@ -105,7 +108,7 @@ func TestConfigUnsetValueNoMatchIsNotError(t *testing.T) { fake := &fakeRunner{err: &CommandError{ExitCode: 5}} config := At("", WithRunner(fake)).Config() - err := config.UnsetValue(context.Background(), "credential.helper", "!my-helper", ScopeSystem) + err := config.UnsetValue(context.Background(), credentialHelperKey, "!my-helper", ScopeSystem) assert.NilError(t, err) } @@ -113,7 +116,7 @@ func TestConfigUnsetValueRealFailurePropagates(t *testing.T) { fake := &fakeRunner{err: &CommandError{ExitCode: 128, Stderr: "fatal: bad config"}} config := At("", WithRunner(fake)).Config() - err := config.UnsetValue(context.Background(), "credential.helper", "!my-helper", ScopeSystem) + err := config.UnsetValue(context.Background(), credentialHelperKey, "!my-helper", ScopeSystem) assert.Assert(t, err != nil) var cmdErr *CommandError From 6694f7484eac52e2449a45214cbf66cedf4d710d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 3 Sep 2026 22:15:59 +0000 Subject: [PATCH 04/17] fix: resolve remaining SOPS review issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/workspace/up/secrets_test.go | 15 ++++++++------- pkg/git/inspect.go | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go index 454f8c01c..ada71fdee 100644 --- a/cmd/workspace/up/secrets_test.go +++ b/cmd/workspace/up/secrets_test.go @@ -15,6 +15,7 @@ const ( secretAPIKey = "API_KEY" secretToken = "TOKEN" secretTLSKey = "TLS_KEY" + testNPMToken = "NPM_TOKEN" ) func testConfig(bound ...string) *config.Config { @@ -166,30 +167,30 @@ func TestApplyEnvVars_AllowsNonSensitive(t *testing.T) { func TestApplyBuildSecretsUsesUnqualifiedKeyAsID(t *testing.T) { cmd := &UpCmd{} - cmd.BuildSecretNames = []string{"sops:project/NPM_TOKEN"} + cmd.BuildSecretNames = []string{"sops:project/" + testNPMToken} resolver := secretspkg.NewResolver() require.NoError(t, resolver.Register("project", "sops", fixedSource{ - values: map[string]string{"NPM_TOKEN": "secret"}, sensitive: true, + values: map[string]string{testNPMToken: "secret"}, sensitive: true, })) err := cmd.applyBuildSecrets(context.Background(), resolver) require.NoError(t, err) - assert.Equal(t, []string{"NPM_TOKEN=secret"}, cmd.BuildSecrets) + assert.Equal(t, []string{testNPMToken + "=secret"}, cmd.BuildSecrets) } func TestApplyBuildSecretsRejectsDuplicateIDs(t *testing.T) { cmd := &UpCmd{} - cmd.BuildSecretNames = []string{"sops:project/NPM_TOKEN", "sops:other/NPM_TOKEN"} + cmd.BuildSecretNames = []string{"sops:project/" + testNPMToken, "sops:other/" + testNPMToken} resolver := secretspkg.NewResolver() require.NoError(t, resolver.Register("project", "sops", fixedSource{ - values: map[string]string{"NPM_TOKEN": "secret-a"}, sensitive: true, + values: map[string]string{testNPMToken: "secret-a"}, sensitive: true, })) require.NoError(t, resolver.Register("other", "sops", fixedSource{ - values: map[string]string{"NPM_TOKEN": "secret-b"}, sensitive: true, + values: map[string]string{testNPMToken: "secret-b"}, sensitive: true, })) err := cmd.applyBuildSecrets(context.Background(), resolver) require.Error(t, err) - assert.Contains(t, err.Error(), "both use BuildKit id \"NPM_TOKEN\"") + assert.Contains(t, err.Error(), "both use BuildKit id \""+testNPMToken+"\"") assert.Empty(t, cmd.BuildSecrets) } diff --git a/pkg/git/inspect.go b/pkg/git/inspect.go index 592898da4..1c7e9b179 100644 --- a/pkg/git/inspect.go +++ b/pkg/git/inspect.go @@ -87,11 +87,11 @@ func fetchInspectionPR( var lastErr error for _, host := range prCandidates(repository) { refspec := host.Refspec(number) - if _, err := repo.run(ctx, "fetch", "--depth=1", "origin", refspec); err == nil { + _, err := repo.run(ctx, "fetch", "--depth=1", "origin", refspec) + if err == nil { return "FETCH_HEAD", nil - } else { - lastErr = err } + lastErr = err } return "", fmt.Errorf("fetch request revision: %w", lastErr) } From 3e765108368af2fc883cfbac666cc94d9c802eb6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 03:17:20 +0000 Subject: [PATCH 05/17] fix(secrets): address review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/secrets/bind.go | 2 +- cmd/workspace/up/up.go | 12 +++++++++++- pkg/secrets/reference.go | 3 +++ pkg/secrets/reference_test.go | 2 +- pkg/secrets/sops_source.go | 9 +++++++-- pkg/secrets/sops_source_test.go | 10 ++++++++++ 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/cmd/secrets/bind.go b/cmd/secrets/bind.go index 302c0e551..0008f69da 100644 --- a/cmd/secrets/bind.go +++ b/cmd/secrets/bind.go @@ -50,7 +50,7 @@ func verifySecretReference( devsyConfig *config.Config, ref secrets.SecretRef, ) error { - if ref.Source == secrets.LocalSourceName { + if ref.Source == secrets.LocalSourceName && (ref.Type == "" || ref.Type == secrets.LocalSourceName) { return verifyLocalSensitive(devsyConfig, devsyConfig.DefaultContext, ref.Name) } resolver, err := secrets.NewResolverForConfig(devsyConfig) diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 53845ef6b..c456d1620 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -123,7 +123,17 @@ func RunHeadless( if err := cmd.validate(); err != nil { return nil, err } - if err := cmd.prepareSecrets(opts.DevsyConfig); err != nil { + var ( + project *projectSecretContext + err error + ) + if cfg := client.WorkspaceConfig(); cfg != nil { + project, err = cmd.discoverProjectSecrets(ctx, &cfg.Source) + if err != nil { + return nil, err + } + } + if err := cmd.prepareSecretsWithProject(ctx, opts.DevsyConfig, project); err != nil { return nil, err } cmd.prepareWorkspace(client) diff --git a/pkg/secrets/reference.go b/pkg/secrets/reference.go index 8963cb62c..ec7d1d404 100644 --- a/pkg/secrets/reference.go +++ b/pkg/secrets/reference.go @@ -48,6 +48,9 @@ func parseQualifiedRef(value string) (SecretRef, error) { if err := ValidateSourceName(sourceName); err != nil { return SecretRef{}, fmt.Errorf("invalid secret reference %q: %w", value, err) } + if err := ValidateName(name); err != nil { + return SecretRef{}, fmt.Errorf("invalid secret reference %q: %w", value, err) + } return SecretRef{Type: typeName, Source: sourceName, Name: name}, nil } diff --git a/pkg/secrets/reference_test.go b/pkg/secrets/reference_test.go index b16723163..203223ab3 100644 --- a/pkg/secrets/reference_test.go +++ b/pkg/secrets/reference_test.go @@ -38,7 +38,7 @@ func TestParseRef(t *testing.T) { } func TestParseRefRejectsMalformedQualifiedRefs(t *testing.T) { - for _, value := range []string{"", "sops:", "sops:project", "sops:/TOKEN", "sops:project/", "sops:project/a/b"} { + for _, value := range []string{"", "sops:", "sops:project", "sops:/TOKEN", "sops:project/", "sops:project/a/b", "sops:project/API=TOKEN"} { t.Run(value, func(t *testing.T) { if _, err := ParseRef(value); err == nil { t.Fatalf("ParseRef(%q) unexpectedly succeeded", value) diff --git a/pkg/secrets/sops_source.go b/pkg/secrets/sops_source.go index 7586ebb68..0cf8c7004 100644 --- a/pkg/secrets/sops_source.go +++ b/pkg/secrets/sops_source.go @@ -1,6 +1,7 @@ package secrets import ( + "bytes" "context" "encoding/json" "fmt" @@ -175,7 +176,9 @@ func parseSOPSDocument(plaintext []byte, format string) (map[string]string, erro var raw map[string]any var err error if format == SOPSFormatJSON { - err = json.Unmarshal(plaintext, &raw) + decoder := json.NewDecoder(bytes.NewReader(plaintext)) + decoder.UseNumber() + err = decoder.Decode(&raw) } else { err = yaml.Unmarshal(plaintext, &raw) } @@ -201,7 +204,9 @@ func stringifySecretScalar(value any) (string, error) { switch v := value.(type) { case string: return v, nil - case bool, int, int64, uint64, float64: + case bool, int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64, json.Number: return fmt.Sprint(v), nil case nil: return "", fmt.Errorf("value is null; only scalar non-null values are supported") diff --git a/pkg/secrets/sops_source_test.go b/pkg/secrets/sops_source_test.go index 23dac962c..aa306798a 100644 --- a/pkg/secrets/sops_source_test.go +++ b/pkg/secrets/sops_source_test.go @@ -79,6 +79,16 @@ func TestParseSOPSDocumentJSON(t *testing.T) { require.Equal(t, "5432", values["PORT"]) } +func TestParseSOPSDocumentJSONPreservesLargeIntegers(t *testing.T) { + values, err := parseSOPSDocument( + []byte(`{"API_TOKEN":"secret","COUNT":9007199254740993}`), + SOPSFormatJSON, + ) + require.NoError(t, err) + require.Equal(t, "secret", values["API_TOKEN"]) + require.Equal(t, "9007199254740993", values["COUNT"]) +} + func TestParseSOPSDocumentDotenv(t *testing.T) { values, err := parseSOPSDocument( []byte("API_TOKEN=secret\nPORT=5432\n"), From dc2dc095db5a852afaf65652de72cae0fd3079d2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 03:34:58 +0000 Subject: [PATCH 06/17] fix(secrets): satisfy lint formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/secrets/bind.go | 3 ++- pkg/secrets/reference_test.go | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd/secrets/bind.go b/cmd/secrets/bind.go index 0008f69da..09e812776 100644 --- a/cmd/secrets/bind.go +++ b/cmd/secrets/bind.go @@ -50,7 +50,8 @@ func verifySecretReference( devsyConfig *config.Config, ref secrets.SecretRef, ) error { - if ref.Source == secrets.LocalSourceName && (ref.Type == "" || ref.Type == secrets.LocalSourceName) { + if ref.Source == secrets.LocalSourceName && + (ref.Type == "" || ref.Type == secrets.LocalSourceName) { return verifyLocalSensitive(devsyConfig, devsyConfig.DefaultContext, ref.Name) } resolver, err := secrets.NewResolverForConfig(devsyConfig) diff --git a/pkg/secrets/reference_test.go b/pkg/secrets/reference_test.go index 203223ab3..98110babd 100644 --- a/pkg/secrets/reference_test.go +++ b/pkg/secrets/reference_test.go @@ -38,7 +38,15 @@ func TestParseRef(t *testing.T) { } func TestParseRefRejectsMalformedQualifiedRefs(t *testing.T) { - for _, value := range []string{"", "sops:", "sops:project", "sops:/TOKEN", "sops:project/", "sops:project/a/b", "sops:project/API=TOKEN"} { + for _, value := range []string{ + "", + "sops:", + "sops:project", + "sops:/TOKEN", + "sops:project/", + "sops:project/a/b", + "sops:project/API=TOKEN", + } { t.Run(value, func(t *testing.T) { if _, err := ParseRef(value); err == nil { t.Fatalf("ParseRef(%q) unexpectedly succeeded", value) From 00fcef891112a7b65d7766d1c9aec874f493ce41 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 04:28:16 +0000 Subject: [PATCH 07/17] test(sops): avoid empty age command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/tests/up/sops.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/e2e/tests/up/sops.go b/e2e/tests/up/sops.go index 965d84eb1..f2b62aa6c 100644 --- a/e2e/tests/up/sops.go +++ b/e2e/tests/up/sops.go @@ -134,8 +134,8 @@ var _ = ginkgo.Describe( func useSOPSAgeIdentity() { setSOPSEnv("SOPS_AGE_KEY", sopsE2EAgeIdentity) - setSOPSEnv("SOPS_AGE_KEY_FILE", "") - setSOPSEnv("SOPS_AGE_KEY_CMD", "") + unsetSOPSEnv("SOPS_AGE_KEY_FILE") + unsetSOPSEnv("SOPS_AGE_KEY_CMD") } func setSOPSEnv(name, value string) { @@ -150,6 +150,18 @@ func setSOPSEnv(name, value string) { }) } +func unsetSOPSEnv(name string) { + previous, had := os.LookupEnv(name) + framework.ExpectNoError(os.Unsetenv(name)) + ginkgo.DeferCleanup(func() { + if had { + _ = os.Setenv(name, previous) + return + } + _ = os.Unsetenv(name) + }) +} + func registerSOPSSource(ctx context.Context, dtc *dockerTestContext, name, filePath string) { _, err := dtc.f.ExecCommandOutput( ctx, From 9bc6d7fc24d22549084ea47428937356ee145697 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 04:28:17 +0000 Subject: [PATCH 08/17] ci: stabilize Linux Podman E2E setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-ci.yml | 39 +++++++--------- hack/ci/setup-podman-linux.sh | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 24 deletions(-) create mode 100755 hack/ci/setup-podman-linux.sh diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 4c682d64f..1d85e1f01 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -423,7 +423,7 @@ jobs: requires-secret: false - label: up-provider-podman-rootless-basic - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -433,7 +433,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootless-exec - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -443,7 +443,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootless-lifecycle - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -453,7 +453,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootless-config - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -463,7 +463,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootless-features - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -473,7 +473,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootful-basic - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -483,7 +483,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootful-lifecycle - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -493,7 +493,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootful-lifecycle-2 - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -503,7 +503,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootful-config - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -513,7 +513,7 @@ jobs: flake-attempts: 2 - label: up-provider-podman-rootful-features - runner: ubuntu-latest + runner: ubuntu-24.04 free-disk-space: false install-kind: false requires-secret: false @@ -781,26 +781,17 @@ jobs: command -v newuidmap >/dev/null || sudo apt-get install -y uidmap - - name: Install Podman (Linux rootless) + - name: Install Podman (Linux) if: matrix.install-podman == 'rootless' && runner.os == 'Linux' + shell: bash run: | - podman info - podman run --rm busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d echo "podman runtime preflight OK" + ./hack/ci/setup-podman-linux.sh rootless - name: Install Podman (Linux rootful) if: matrix.install-podman == 'rootful' && runner.os == 'Linux' + shell: bash run: | - sudo systemctl daemon-reload - sudo systemctl enable --now podman.socket - if ! timeout 30 bash -c 'until sudo podman --remote --url unix:///run/podman/podman.sock info >/dev/null 2>&1; do sleep 1; done'; then - echo "::error::podman service did not become ready within 30s" - sudo systemctl status podman.socket --no-pager || true - sudo journalctl -u podman.socket --no-pager -n 100 || true - exit 1 - fi - echo "DOCKER_HOST=unix:///run/podman/podman.sock" >> "$GITHUB_ENV" - sudo podman --remote --url unix:///run/podman/podman.sock info - sudo podman run --rm busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d echo "podman runtime preflight OK" + ./hack/ci/setup-podman-linux.sh rootful - name: get microsandbox latest version if: matrix.install-microsandbox == true && runner.os == 'Linux' diff --git a/hack/ci/setup-podman-linux.sh b/hack/ci/setup-podman-linux.sh new file mode 100755 index 000000000..cb146abf1 --- /dev/null +++ b/hack/ci/setup-podman-linux.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +set -euo pipefail + +mode="${1:-}" +if [[ "$mode" != "rootless" && "$mode" != "rootful" ]]; then + echo "::error::usage: $0 " + exit 2 +fi + +export PATH="/usr/local/bin:$PATH" + +if [[ ! -x /usr/local/bin/podman ]]; then + echo "::error::Expected Podman at /usr/local/bin/podman" + exit 1 +fi +if [[ ! -x /usr/local/bin/crun ]]; then + echo "::error::Expected bundled crun at /usr/local/bin/crun" + exit 1 +fi + +sudo mkdir -p /etc/containers/containers.conf.d +sudo tee /etc/containers/containers.conf.d/99-devsy-ci.conf >/dev/null <<'EOF' +[engine] +runtime = "crun" + +[engine.runtimes] +crun = ["/usr/local/bin/crun"] +EOF + +if [[ -f /etc/apparmor.d/podman ]]; then + sudo sed -Ei \ + 's!^profile podman /usr/bin/podman !profile podman /usr/{bin,local/bin}/podman !' \ + /etc/apparmor.d/podman + sudo apparmor_parser -r /etc/apparmor.d/podman +fi + +echo "Podman executable: $(command -v podman)" +podman --version +/usr/local/bin/crun --version +cat /etc/os-release +uname -a + +if [[ "$mode" == "rootful" ]]; then + sudo systemctl daemon-reload + sudo systemctl enable --now podman.socket + if ! timeout 30 bash -c \ + 'until sudo podman --remote --url unix:///run/podman/podman.sock info >/dev/null 2>&1; do sleep 1; done' + then + echo "::error::podman service did not become ready within 30s" + sudo systemctl status podman.socket --no-pager || true + sudo systemctl status podman.service --no-pager || true + sudo journalctl -u podman.socket --no-pager -n 100 || true + sudo journalctl -u podman.service --no-pager -n 100 || true + exit 1 + fi + + runtime_path="$( + sudo podman --remote --url unix:///run/podman/podman.sock \ + info --format '{{.Host.OCIRuntime.Path}}' + )" + echo "Podman OCI runtime: $runtime_path" + if [[ "$runtime_path" != "/usr/local/bin/crun" ]]; then + echo "::error::Unexpected Podman OCI runtime: $runtime_path" + exit 1 + fi + + echo "DOCKER_HOST=unix:///run/podman/podman.sock" >> "$GITHUB_ENV" + sudo podman --remote --url unix:///run/podman/podman.sock info + sudo podman --remote --url unix:///run/podman/podman.sock run --rm \ + busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d \ + true +else + runtime_path="$(podman info --format '{{.Host.OCIRuntime.Path}}')" + echo "Podman OCI runtime: $runtime_path" + if [[ "$runtime_path" != "/usr/local/bin/crun" ]]; then + echo "::error::Unexpected Podman OCI runtime: $runtime_path" + exit 1 + fi + + podman info + podman run --rm \ + busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d \ + true +fi From 0c0df58c407dface7406846d70e1beb44d29f34c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 05:29:09 +0000 Subject: [PATCH 09/17] style(ci): apply shfmt formatting to podman setup script Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hack/ci/setup-podman-linux.sh | 91 +++++++++++++++++------------------ 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/hack/ci/setup-podman-linux.sh b/hack/ci/setup-podman-linux.sh index cb146abf1..acfbb4b33 100755 --- a/hack/ci/setup-podman-linux.sh +++ b/hack/ci/setup-podman-linux.sh @@ -4,19 +4,19 @@ set -euo pipefail mode="${1:-}" if [[ "$mode" != "rootless" && "$mode" != "rootful" ]]; then - echo "::error::usage: $0 " - exit 2 + echo "::error::usage: $0 " + exit 2 fi export PATH="/usr/local/bin:$PATH" if [[ ! -x /usr/local/bin/podman ]]; then - echo "::error::Expected Podman at /usr/local/bin/podman" - exit 1 + echo "::error::Expected Podman at /usr/local/bin/podman" + exit 1 fi if [[ ! -x /usr/local/bin/crun ]]; then - echo "::error::Expected bundled crun at /usr/local/bin/crun" - exit 1 + echo "::error::Expected bundled crun at /usr/local/bin/crun" + exit 1 fi sudo mkdir -p /etc/containers/containers.conf.d @@ -29,10 +29,10 @@ crun = ["/usr/local/bin/crun"] EOF if [[ -f /etc/apparmor.d/podman ]]; then - sudo sed -Ei \ - 's!^profile podman /usr/bin/podman !profile podman /usr/{bin,local/bin}/podman !' \ - /etc/apparmor.d/podman - sudo apparmor_parser -r /etc/apparmor.d/podman + sudo sed -Ei \ + 's!^profile podman /usr/bin/podman !profile podman /usr/{bin,local/bin}/podman !' \ + /etc/apparmor.d/podman + sudo apparmor_parser -r /etc/apparmor.d/podman fi echo "Podman executable: $(command -v podman)" @@ -42,44 +42,43 @@ cat /etc/os-release uname -a if [[ "$mode" == "rootful" ]]; then - sudo systemctl daemon-reload - sudo systemctl enable --now podman.socket - if ! timeout 30 bash -c \ - 'until sudo podman --remote --url unix:///run/podman/podman.sock info >/dev/null 2>&1; do sleep 1; done' - then - echo "::error::podman service did not become ready within 30s" - sudo systemctl status podman.socket --no-pager || true - sudo systemctl status podman.service --no-pager || true - sudo journalctl -u podman.socket --no-pager -n 100 || true - sudo journalctl -u podman.service --no-pager -n 100 || true - exit 1 - fi + sudo systemctl daemon-reload + sudo systemctl enable --now podman.socket + if ! timeout 30 bash -c \ + 'until sudo podman --remote --url unix:///run/podman/podman.sock info >/dev/null 2>&1; do sleep 1; done'; then + echo "::error::podman service did not become ready within 30s" + sudo systemctl status podman.socket --no-pager || true + sudo systemctl status podman.service --no-pager || true + sudo journalctl -u podman.socket --no-pager -n 100 || true + sudo journalctl -u podman.service --no-pager -n 100 || true + exit 1 + fi - runtime_path="$( - sudo podman --remote --url unix:///run/podman/podman.sock \ - info --format '{{.Host.OCIRuntime.Path}}' - )" - echo "Podman OCI runtime: $runtime_path" - if [[ "$runtime_path" != "/usr/local/bin/crun" ]]; then - echo "::error::Unexpected Podman OCI runtime: $runtime_path" - exit 1 - fi + runtime_path="$( + sudo podman --remote --url unix:///run/podman/podman.sock \ + info --format '{{.Host.OCIRuntime.Path}}' + )" + echo "Podman OCI runtime: $runtime_path" + if [[ "$runtime_path" != "/usr/local/bin/crun" ]]; then + echo "::error::Unexpected Podman OCI runtime: $runtime_path" + exit 1 + fi - echo "DOCKER_HOST=unix:///run/podman/podman.sock" >> "$GITHUB_ENV" - sudo podman --remote --url unix:///run/podman/podman.sock info - sudo podman --remote --url unix:///run/podman/podman.sock run --rm \ - busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d \ - true + echo "DOCKER_HOST=unix:///run/podman/podman.sock" >>"$GITHUB_ENV" + sudo podman --remote --url unix:///run/podman/podman.sock info + sudo podman --remote --url unix:///run/podman/podman.sock run --rm \ + busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d \ + true else - runtime_path="$(podman info --format '{{.Host.OCIRuntime.Path}}')" - echo "Podman OCI runtime: $runtime_path" - if [[ "$runtime_path" != "/usr/local/bin/crun" ]]; then - echo "::error::Unexpected Podman OCI runtime: $runtime_path" - exit 1 - fi + runtime_path="$(podman info --format '{{.Host.OCIRuntime.Path}}')" + echo "Podman OCI runtime: $runtime_path" + if [[ "$runtime_path" != "/usr/local/bin/crun" ]]; then + echo "::error::Unexpected Podman OCI runtime: $runtime_path" + exit 1 + fi - podman info - podman run --rm \ - busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d \ - true + podman info + podman run --rm \ + busybox@sha256:fd8d9aa63ba2f0982b5304e1ee8d3b90a210bc1ffb5314d980eb6962f1a9715d \ + true fi From eb56dcb5349cba64c905b5d009e08c635de2ade1 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 06:08:50 +0000 Subject: [PATCH 10/17] fix(secrets): resolve bootstrap git token in headless up and reject trailing JSON - RunHeadless now resolves the bootstrap Git token before discovering project secrets, so authenticated remote repository inspection (and cmd.GitToken use elsewhere) works from the headless path, matching prepareWorkspaceSecrets. - parseSOPSDocument now rejects trailing content after the first JSON value instead of silently ignoring it, with a regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/workspace/up/up.go | 7 +++++++ pkg/secrets/sops_source.go | 26 +++++++++++++++++++++++--- pkg/secrets/sops_source_test.go | 14 ++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index c456d1620..7397a377e 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -128,6 +128,13 @@ func RunHeadless( err error ) if cfg := client.WorkspaceConfig(); cfg != nil { + // Bootstrap credentials must be resolved before repository-owned + // secret sources are discovered, mirroring prepareWorkspaceSecrets, + // so that authenticated remote inspection (and any later use of + // cmd.GitToken) works from the headless path too. + if err := cmd.prepareBootstrapGitToken(ctx, opts.DevsyConfig, &cfg.Source); err != nil { + return nil, err + } project, err = cmd.discoverProjectSecrets(ctx, &cfg.Source) if err != nil { return nil, err diff --git a/pkg/secrets/sops_source.go b/pkg/secrets/sops_source.go index 0cf8c7004..7e83110ee 100644 --- a/pkg/secrets/sops_source.go +++ b/pkg/secrets/sops_source.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -164,6 +166,26 @@ func canonicalSOPSFormat(format string) (string, error) { } } +// decodeSOPSJSON decodes a single JSON value from plaintext, preserving +// large integers via json.Number, and rejects any trailing content after +// the first value (e.g. a second top-level value or stray text) that +// json.Decoder would otherwise silently ignore. +func decodeSOPSJSON(plaintext []byte, raw *map[string]any) error { + decoder := json.NewDecoder(bytes.NewReader(plaintext)) + decoder.UseNumber() + if err := decoder.Decode(raw); err != nil { + return err + } + var extra json.RawMessage + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("unexpected trailing data after JSON document") + } + return fmt.Errorf("unexpected trailing data after JSON document: %w", err) + } + return nil +} + func parseSOPSDocument(plaintext []byte, format string) (map[string]string, error) { if format == SOPSFormatDotenv { values, err := godotenv.Unmarshal(string(plaintext)) @@ -176,9 +198,7 @@ func parseSOPSDocument(plaintext []byte, format string) (map[string]string, erro var raw map[string]any var err error if format == SOPSFormatJSON { - decoder := json.NewDecoder(bytes.NewReader(plaintext)) - decoder.UseNumber() - err = decoder.Decode(&raw) + err = decodeSOPSJSON(plaintext, &raw) } else { err = yaml.Unmarshal(plaintext, &raw) } diff --git a/pkg/secrets/sops_source_test.go b/pkg/secrets/sops_source_test.go index aa306798a..c30931432 100644 --- a/pkg/secrets/sops_source_test.go +++ b/pkg/secrets/sops_source_test.go @@ -89,6 +89,20 @@ func TestParseSOPSDocumentJSONPreservesLargeIntegers(t *testing.T) { require.Equal(t, "9007199254740993", values["COUNT"]) } +func TestParseSOPSDocumentJSONRejectsTrailingData(t *testing.T) { + _, err := parseSOPSDocument( + []byte(`{"API_TOKEN":"secret"}{"extra":"value"}`), + SOPSFormatJSON, + ) + require.ErrorContains(t, err, "trailing data") + + _, err = parseSOPSDocument( + []byte(`{"API_TOKEN":"secret"} garbage`), + SOPSFormatJSON, + ) + require.ErrorContains(t, err, "trailing data") +} + func TestParseSOPSDocumentDotenv(t *testing.T) { values, err := parseSOPSDocument( []byte("API_TOKEN=secret\nPORT=5432\n"), From 853fd5ad35b50f378b2851427061c29453fe2729 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 06:46:28 +0000 Subject: [PATCH 11/17] fix(git): honor subpath when discovering repository secrets Inspection.ReadFile ignored info.SubPath entirely, so remote workspaces using an @subpath: selector had their .devsy/config.yaml and SOPS source paths read from the repository root instead of the selected subproject. This left subproject SOPS secrets unresolved or, worse, loaded an unrelated root configuration. Inspection now stores the cleaned subpath from GitInfo and ReadFile joins it onto every repository-relative path it resolves, mirroring how devcontainer discovery already handles GitSubPath. The subpath is validated the same way project-owned source paths are, rejecting absolute paths and any attempt to escape the repository root. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/git/inspect.go | 48 ++++++++++++++++++++++++++--- pkg/git/inspect_test.go | 68 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 pkg/git/inspect_test.go diff --git a/pkg/git/inspect.go b/pkg/git/inspect.go index 1c7e9b179..d199e9291 100644 --- a/pkg/git/inspect.go +++ b/pkg/git/inspect.go @@ -5,11 +5,17 @@ import ( "errors" "fmt" "os" + "path" "path/filepath" "strings" ) -const bloblessCloneFilter = "--filter=blob:none" +const ( + bloblessCloneFilter = "--filter=blob:none" + // inspectionHeadRev is the default revision selector used when the + // inspection carries no explicit commit or PR reference. + inspectionHeadRev = "HEAD" +) var ErrRevisionPathNotFound = errors.New("path not found in git revision") @@ -19,6 +25,10 @@ type Inspection struct { repo *Repo rev string root string + // subPath is the repository-relative directory that ReadFile treats as + // the project root, mirroring info.SubPath (the @subpath: selector). + // Empty means the repository root. + subPath string } // InspectRemote creates a temporary blobless clone and selects the exact @@ -42,7 +52,12 @@ func InspectRemote(ctx context.Context, info *GitInfo, env []string) (*Inspectio _ = os.RemoveAll(root) return nil, err } - return &Inspection{repo: repo, rev: rev, root: root}, nil + subPath, err := cleanInspectionSubPath(info.SubPath) + if err != nil { + _ = os.RemoveAll(root) + return nil, err + } + return &Inspection{repo: repo, rev: rev, root: root, subPath: subPath}, nil } func cloneInspectionRepo( @@ -72,7 +87,7 @@ func selectInspectionRevision(ctx context.Context, repo *Repo, info *GitInfo) (s if info.Commit != "" { return fetchInspectionCommit(ctx, repo, info.Commit) } - return "HEAD", nil + return inspectionHeadRev, nil } func fetchInspectionPR( @@ -103,13 +118,17 @@ func fetchInspectionCommit(ctx context.Context, repo *Repo, commit string) (stri return "FETCH_HEAD", nil } -// ReadFile returns the bytes for a repository-relative path at the exact +// ReadFile returns the bytes for a path relative to the selected subpath +// project root (or the repository root when no subpath is set) at the exact // revision selected by InspectRemote. func (i *Inspection) ReadFile(ctx context.Context, filePath string) ([]byte, error) { if i == nil || i.repo == nil { return nil, fmt.Errorf("git inspection is closed") } filePath = strings.TrimPrefix(strings.ReplaceAll(filePath, "\\", "/"), "./") + if i.subPath != "" { + filePath = path.Join(i.subPath, filePath) + } object := i.rev + ":" + filePath if _, err := i.repo.run(ctx, "cat-file", "-e", object); err != nil { return nil, fmt.Errorf("%w: %s", ErrRevisionPathNotFound, filePath) @@ -121,6 +140,27 @@ func (i *Inspection) ReadFile(ctx context.Context, filePath string) ([]byte, err return append([]byte(nil), result.Stdout...), nil } +// cleanInspectionSubPath normalizes and validates a repository-relative +// subpath selector (from an @subpath: reference), rejecting anything that +// would escape the repository root once joined with a file path. +func cleanInspectionSubPath(value string) (string, error) { + value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + if value == "" { + return "", nil + } + if strings.HasPrefix(value, "/") { + return "", fmt.Errorf("git subpath %q must be relative to the repository root", value) + } + clean := path.Clean(value) + if clean == "." { + return "", nil + } + if clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("git subpath %q escapes the repository root", value) + } + return clean, nil +} + func (i *Inspection) Revision() string { if i == nil { return "" diff --git a/pkg/git/inspect_test.go b/pkg/git/inspect_test.go new file mode 100644 index 000000000..710ec48bb --- /dev/null +++ b/pkg/git/inspect_test.go @@ -0,0 +1,68 @@ +package git + +import ( + "context" + "testing" + + "gotest.tools/assert" +) + +const testSubPath = "apps/foo" + +// TestInspectionReadFileUsesSubPath verifies that ReadFile resolves paths +// relative to the selected @subpath: project root instead of the repository +// root, so repository-owned config (e.g. .devsy/config.yaml) and SOPS source +// files inside a subproject are discovered correctly. +func TestInspectionReadFileUsesSubPath(t *testing.T) { + runner := &fakeRunner{stdout: []byte("secret-contents")} + repo := At("/tmp/repo", WithRunner(runner)) + inspection := &Inspection{repo: repo, rev: inspectionHeadRev, subPath: testSubPath} + + out, err := inspection.ReadFile(context.Background(), ".devsy/config.yaml") + assert.NilError(t, err) + assert.Equal(t, string(out), "secret-contents") + + wantObject := inspectionHeadRev + ":" + testSubPath + "/.devsy/config.yaml" + // cat-file existence check, then show; both must target the subpath. + assert.Equal(t, len(runner.calls), 2) + assert.Equal(t, runner.calls[0].Args[len(runner.calls[0].Args)-1], wantObject) + assert.Equal(t, runner.calls[1].Args[len(runner.calls[1].Args)-1], wantObject) +} + +func TestInspectionReadFileWithoutSubPathUsesRepoRoot(t *testing.T) { + runner := &fakeRunner{stdout: []byte("secret-contents")} + repo := At("/tmp/repo", WithRunner(runner)) + inspection := &Inspection{repo: repo, rev: inspectionHeadRev} + + _, err := inspection.ReadFile(context.Background(), ".devsy/config.yaml") + assert.NilError(t, err) + wantObject := inspectionHeadRev + ":.devsy/config.yaml" + assert.Equal(t, runner.calls[0].Args[len(runner.calls[0].Args)-1], wantObject) +} + +func TestCleanInspectionSubPath(t *testing.T) { + for _, tc := range []struct { + name string + value string + want string + wantErr bool + }{ + {name: "empty", value: "", want: ""}, + {name: "dot", value: ".", want: ""}, + {name: "simple", value: testSubPath, want: testSubPath}, + {name: "trailing slash", value: testSubPath + "/", want: testSubPath}, + {name: "absolute rejected", value: "/apps/bar", wantErr: true}, + {name: "parent escape rejected", value: "../bar", wantErr: true}, + {name: "parent only rejected", value: "..", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := cleanInspectionSubPath(tc.value) + if tc.wantErr { + assert.Assert(t, err != nil) + return + } + assert.NilError(t, err) + assert.Equal(t, got, tc.want) + }) + } +} From d3072b072bf126518cd7f0b474008258c5bf0d16 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 08:22:47 +0000 Subject: [PATCH 12/17] fix(up): discover project secrets after workspace source resolution Repository-owned project secret auto-discovery (e.g. SOPS sources declared in .devsy/config.yaml) never worked for an ordinary `devsy up ` CLI invocation: discovery ran against the source returned by parseWorkspaceSource, which is only populated by --source/--from-snapshot, not by a positional workspace argument. The actual workspace source (local folder, git repository, or image) is only known once workspace2.Resolve classifies it. prepareClient now runs project secret discovery after Resolve returns, deriving the source from client.WorkspaceConfig().Source, mirroring the pattern already used successfully in RunHeadless. Bootstrap git token resolution is unchanged and still runs before Resolve. Fixes the failing 'discovers repository-owned SOPS sources and attached secrets' up-provider-docker E2E test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/workspace/up/project_secrets_test.go | 96 +++++++++++++++++++ .../up/testdata/sops-project-secrets.enc.yaml | 17 ++++ cmd/workspace/up/up.go | 5 +- cmd/workspace/up/up_client.go | 40 +++++--- 4 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 cmd/workspace/up/project_secrets_test.go create mode 100644 cmd/workspace/up/testdata/sops-project-secrets.enc.yaml diff --git a/cmd/workspace/up/project_secrets_test.go b/cmd/workspace/up/project_secrets_test.go new file mode 100644 index 000000000..78c65e8c9 --- /dev/null +++ b/cmd/workspace/up/project_secrets_test.go @@ -0,0 +1,96 @@ +package up + +import ( + "context" + "os" + "path/filepath" + "testing" + + client2 "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/require" +) + +// fakeWorkspaceClient is a minimal client2.BaseWorkspaceClient used to +// exercise prepareResolvedWorkspaceSecrets without a real provider. +type fakeWorkspaceClient struct { + config *provider.Workspace +} + +func (f *fakeWorkspaceClient) Provider() string { return "" } +func (f *fakeWorkspaceClient) Context() string { return "" } +func (f *fakeWorkspaceClient) RefreshOptions(context.Context, []string, bool) error { + return nil +} + +func (f *fakeWorkspaceClient) Status( + context.Context, + client2.StatusOptions, +) (client2.Status, error) { + return client2.StatusRunning, nil +} +func (f *fakeWorkspaceClient) Stop(context.Context, client2.StopOptions) error { return nil } +func (f *fakeWorkspaceClient) Delete(context.Context, client2.DeleteOptions) error { return nil } + +func (f *fakeWorkspaceClient) Workspace() string { return "" } + +func (f *fakeWorkspaceClient) WorkspaceConfig() *provider.Workspace { return f.config } +func (f *fakeWorkspaceClient) Lock(context.Context) error { return nil } +func (f *fakeWorkspaceClient) Unlock() {} + +const ( + // Synthetic test-only identity generated solely for this fixture. gitleaks:allow. + testProjectSecretsAgeIdentity = "AGE-SECRET-KEY-12UWYSAH2MRDQ5K4EWC4253PDTCSCS32Y5EFQ8TEN2SL3QYU2GN2SG88CZX" + testProjectSecretsPlaintext = "SUPER_SECRET_TEST_VALUE_7B91" + testProjectEncryptedFixture = "testdata/sops-project-secrets.enc.yaml" +) + +// newTestProjectWorkspace writes a .devsy/config.yaml declaring a repository- +// owned SOPS source plus an attached secret, and the matching encrypted +// document, under a fresh temp directory. It returns a fake client whose +// WorkspaceConfig().Source.LocalFolder points at that directory, mirroring +// what workspace2.Resolve produces for a local-folder positional argument. +func newTestProjectWorkspace(t *testing.T) *fakeWorkspaceClient { + t.Helper() + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, ".devsy"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(root, ".devsy", "config.yaml"), + []byte("secretSources:\n"+ + " - name: project\n"+ + " type: sops\n"+ + " path: secrets.enc.yaml\n"+ + "secrets:\n"+ + " - sops:project/SOPS_E2E_SECRET\n"), + 0o600, + )) + fixture, err := os.ReadFile(testProjectEncryptedFixture) + require.NoError(t, err) + require.NoError(t, os.WriteFile( // #nosec G703 -- t.TempDir()-derived path + filepath.Join(root, "secrets.enc.yaml"), + fixture, + 0o600, + )) + + return &fakeWorkspaceClient{ + config: &provider.Workspace{Source: provider.WorkspaceSource{LocalFolder: root}}, + } +} + +// TestPrepareResolvedWorkspaceSecrets_DiscoversLocalProjectSecrets is a +// regression test for a bug where repository-owned SOPS secrets declared in +// a local project's .devsy/config.yaml were never discovered for ordinary +// `devsy up ` CLI invocations: project discovery ran against the +// source returned by parseWorkspaceSource (populated only by --source/ +// --from-snapshot), not the workspace source workspace2.Resolve derives +// from a positional argument. +func TestPrepareResolvedWorkspaceSecrets_DiscoversLocalProjectSecrets(t *testing.T) { + t.Setenv("SOPS_AGE_KEY", testProjectSecretsAgeIdentity) + client := newTestProjectWorkspace(t) + + cmd := &UpCmd{} + err := cmd.prepareResolvedWorkspaceSecrets(context.Background(), testConfig(), client) + require.NoError(t, err) + + require.Contains(t, cmd.SecretsEnv, "SOPS_E2E_SECRET="+testProjectSecretsPlaintext) +} diff --git a/cmd/workspace/up/testdata/sops-project-secrets.enc.yaml b/cmd/workspace/up/testdata/sops-project-secrets.enc.yaml new file mode 100644 index 000000000..79a5fdfd8 --- /dev/null +++ b/cmd/workspace/up/testdata/sops-project-secrets.enc.yaml @@ -0,0 +1,17 @@ +SOPS_E2E_SECRET: ENC[AES256_GCM,data:7/LkTGbUbM24m2YegXZhCPwFErYDhnRC3jflQA==,iv:jox9Lg827lNean0A3dbFnjbizHq/+e5Oj41ntzFKtNc=,tag:H0XcdWPIYBmte+v0F9j47Q==,type:str] +TLS_KEY: ENC[AES256_GCM,data:7qUD3Paq3RT0XbF/qvSnfw==,iv:ClkYTnncQRPn3JhDZ9iZfuY0lNDYY71ot6HRjF2HCVY=,tag:oGoczTS56kPXL9N4/FY+nA==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwWHBIUkxaRWlsdFRidWFF + OHdHTHZGZUh2UGRzOXhVdFlxRnZIZkY3eTFnCnpoTFNrNVZKcFRtTVhjbnBUK1c1 + SGFnd2R0ZGQvOHFueWpHR0xiOGVvdTAKLS0tIDJTSVFCazNueGJkMlZDUU9vK21j + d0xPTEV4NVZGL1EweHBqM3lTSG9sb3MKJtSnyqRMtWM2WRIEJSHQPkYtBXa+wSpG + PXamDLnUGIrzsjbl6uTZxHytVSwfVERwWzagJTEOajIJpFwHIyGD/A== + -----END AGE ENCRYPTED FILE----- + recipient: age1h84qv0p8kdwfp6vrag4nvmpd6gt3mde9g6dcnj9uryh3qcl50uxq83n932 + lastmodified: "2026-09-03T05:38:40Z" + mac: ENC[AES256_GCM,data:5thaKGP8s7dbN/9VvRn54TB0R6UaRlRvtyoby0yn5gGlCAYZLvBlAXmTY8AQ0ShQII3ictP2KpXVLGd0dp0H0LXGr9PpsdXIVEO5t/WS5YO+ZhdxZ5hgWBzEL1rZ2VO9Bt0DPFmEYNNryqIpLU64lbd+AO5P/6/uaBMZZERYE0Y=,iv:7nUHdzpyZidt2DRQdvSv2W2GCF5JArFzvyX9ppVEP5k=,tag:/KTQAguMfRBpWPDumiTunA==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.3 diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 7397a377e..8407cc39f 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -129,8 +129,9 @@ func RunHeadless( ) if cfg := client.WorkspaceConfig(); cfg != nil { // Bootstrap credentials must be resolved before repository-owned - // secret sources are discovered, mirroring prepareWorkspaceSecrets, - // so that authenticated remote inspection (and any later use of + // secret sources are discovered, mirroring + // prepareResolvedWorkspaceSecrets/prepareBootstrapGitToken, so that + // authenticated remote inspection (and any later use of // cmd.GitToken) works from the headless path too. if err := cmd.prepareBootstrapGitToken(ctx, opts.DevsyConfig, &cfg.Source); err != nil { return nil, err diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 436679f13..822f9aa5c 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -72,7 +72,10 @@ func (cmd *UpCmd) prepareClient( if err != nil { return nil, err } - if err := cmd.prepareWorkspaceSecrets(ctx, devsyConfig, source); err != nil { + // Bootstrap credentials are resolved exclusively from sources that are + // available before repository acquisition. Repository-owned sources are + // deliberately not registered yet, which prevents circular clone auth. + if err := cmd.prepareBootstrapGitToken(ctx, devsyConfig, source); err != nil { return nil, err } @@ -88,6 +91,17 @@ func (cmd *UpCmd) prepareClient( if err != nil { return nil, err } + + // The workspace source (local folder, git repository, or image) is only + // fully known once Resolve has determined it, e.g. from a positional + // workspace argument rather than --source/--from-snapshot. Repository- + // owned secret discovery must therefore happen after Resolve, using the + // client's resolved WorkspaceConfig().Source, not the possibly-nil + // source parsed above. + if err := cmd.prepareResolvedWorkspaceSecrets(ctx, devsyConfig, client); err != nil { + return nil, err + } + if err := cmd.checkProviderUpdate(ctx, devsyConfig, client); err != nil { return nil, err } @@ -110,16 +124,20 @@ func (cmd *UpCmd) prepareClientEnvironment( return cmd.validateFromSnapshot(ctx, args) } -func (cmd *UpCmd) prepareWorkspaceSecrets( +// prepareResolvedWorkspaceSecrets discovers repository-owned project secrets +// (e.g. SOPS sources declared in .devsy/config.yaml) using the workspace's +// fully resolved source and merges them with attached/explicit secrets. +// This must run after workspace2.Resolve, since a positional workspace +// argument's local-folder/git-repository/image classification is only known +// once Resolve has determined client.WorkspaceConfig().Source. +func (cmd *UpCmd) prepareResolvedWorkspaceSecrets( ctx context.Context, devsyConfig *config.Config, - source *provider2.WorkspaceSource, + client client2.BaseWorkspaceClient, ) error { - // Bootstrap credentials are resolved exclusively from sources that are - // available before repository acquisition. Repository-owned sources are - // deliberately not registered yet, which prevents circular clone auth. - if err := cmd.prepareBootstrapGitToken(ctx, devsyConfig, source); err != nil { - return err + var source *provider2.WorkspaceSource + if cfg := client.WorkspaceConfig(); cfg != nil { + source = &cfg.Source } projectSecrets, err := cmd.discoverProjectSecrets(ctx, source) if err != nil { @@ -175,12 +193,6 @@ func (cmd *UpCmd) resolveParams( } } -// prepareSecrets preserves the headless/internal call path where repository -// discovery has already happened elsewhere or is not applicable. -func (cmd *UpCmd) prepareSecrets(devsyConfig *config.Config) error { - return cmd.prepareSecretsWithProject(context.Background(), devsyConfig, nil) -} - func (cmd *UpCmd) prepareSecretsWithProject( ctx context.Context, devsyConfig *config.Config, From ff2244c4e5ee8f4f930b87911baad7b219f6c9fe Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 4 Sep 2026 08:23:00 +0000 Subject: [PATCH 13/17] fix(secrets): harden repo-relative path handling and source registry writes - pkg/git/inspect.go: Inspection.ReadFile now validates and cleans the requested path itself (rejecting absolute paths and .. escapes) before joining it with the selected subpath, instead of relying solely on callers to pre-validate. Shares the escape-prevention logic with the existing @subpath: selector validation. - pkg/secrets/source_config.go: SaveSourceConfigs now holds the same cross-process flock used elsewhere in this package (local secret store) across its load-modify-write sequence, serializing concurrent 'devsy secrets source add/remove' writers instead of racing on secret-sources.yaml. - docs: note that the persisted local SOPS source config also stores the optional --format override, not just name/type/path. Addresses CodeRabbit findings from a full-branch review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/git/inspect.go | 36 ++++++++---- pkg/git/inspect_test.go | 14 +++++ pkg/secrets/source_config.go | 5 ++ pkg/secrets/source_config_lock_test.go | 57 +++++++++++++++++++ .../docs/developing-in-workspaces/secrets.mdx | 5 +- 5 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 pkg/secrets/source_config_lock_test.go diff --git a/pkg/git/inspect.go b/pkg/git/inspect.go index d199e9291..8ac6c974e 100644 --- a/pkg/git/inspect.go +++ b/pkg/git/inspect.go @@ -125,42 +125,56 @@ func (i *Inspection) ReadFile(ctx context.Context, filePath string) ([]byte, err if i == nil || i.repo == nil { return nil, fmt.Errorf("git inspection is closed") } - filePath = strings.TrimPrefix(strings.ReplaceAll(filePath, "\\", "/"), "./") + cleanPath, err := cleanRepoRelativePath("file path", filePath) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + if cleanPath == "" { + return nil, fmt.Errorf("read file: path must not be empty") + } if i.subPath != "" { - filePath = path.Join(i.subPath, filePath) + cleanPath = path.Join(i.subPath, cleanPath) } - object := i.rev + ":" + filePath + object := i.rev + ":" + cleanPath if _, err := i.repo.run(ctx, "cat-file", "-e", object); err != nil { - return nil, fmt.Errorf("%w: %s", ErrRevisionPathNotFound, filePath) + return nil, fmt.Errorf("%w: %s", ErrRevisionPathNotFound, cleanPath) } result, err := i.repo.run(ctx, "show", object) if err != nil { - return nil, fmt.Errorf("read %q from revision %s: %w", filePath, i.rev, err) + return nil, fmt.Errorf("read %q from revision %s: %w", cleanPath, i.rev, err) } return append([]byte(nil), result.Stdout...), nil } -// cleanInspectionSubPath normalizes and validates a repository-relative -// subpath selector (from an @subpath: reference), rejecting anything that -// would escape the repository root once joined with a file path. -func cleanInspectionSubPath(value string) (string, error) { +// cleanRepoRelativePath normalizes and validates a repository-relative path +// (either an @subpath: selector or a file path to read), rejecting anything +// absolute or that would escape the repository root once joined with +// another repository-relative path. +func cleanRepoRelativePath(kind, value string) (string, error) { value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) if value == "" { return "", nil } if strings.HasPrefix(value, "/") { - return "", fmt.Errorf("git subpath %q must be relative to the repository root", value) + return "", fmt.Errorf("git %s %q must be relative to the repository root", kind, value) } clean := path.Clean(value) if clean == "." { return "", nil } if clean == ".." || strings.HasPrefix(clean, "../") { - return "", fmt.Errorf("git subpath %q escapes the repository root", value) + return "", fmt.Errorf("git %s %q escapes the repository root", kind, value) } return clean, nil } +// cleanInspectionSubPath normalizes and validates a repository-relative +// subpath selector (from an @subpath: reference), rejecting anything that +// would escape the repository root once joined with a file path. +func cleanInspectionSubPath(value string) (string, error) { + return cleanRepoRelativePath("subpath", value) +} + func (i *Inspection) Revision() string { if i == nil { return "" diff --git a/pkg/git/inspect_test.go b/pkg/git/inspect_test.go index 710ec48bb..3392e671c 100644 --- a/pkg/git/inspect_test.go +++ b/pkg/git/inspect_test.go @@ -66,3 +66,17 @@ func TestCleanInspectionSubPath(t *testing.T) { }) } } + +// TestInspectionReadFileRejectsPathEscape is a regression test ensuring +// ReadFile itself rejects a path that would escape the repository root (or +// the selected subpath) once cleaned and joined, rather than relying solely +// on callers to pre-validate the path. +func TestInspectionReadFileRejectsPathEscape(t *testing.T) { + runner := &fakeRunner{stdout: []byte("secret-contents")} + repo := At("/tmp/repo", WithRunner(runner)) + inspection := &Inspection{repo: repo, rev: inspectionHeadRev, subPath: testSubPath} + + _, err := inspection.ReadFile(context.Background(), "../../etc/passwd") + assert.Assert(t, err != nil) + assert.Equal(t, len(runner.calls), 0) +} diff --git a/pkg/secrets/source_config.go b/pkg/secrets/source_config.go index 2becf0203..eccd3e74b 100644 --- a/pkg/secrets/source_config.go +++ b/pkg/secrets/source_config.go @@ -61,6 +61,11 @@ func SaveSourceConfigs(devsyConfig *config.Config, sources []SourceConfig) error if err != nil { return err } + unlock, err := acquireFlock(filepath.Dir(filePath), sourceConfigFileName+".lock") + if err != nil { + return err + } + defer unlock() registry, err := loadSourceRegistry(filePath) if err != nil { return err diff --git a/pkg/secrets/source_config_lock_test.go b/pkg/secrets/source_config_lock_test.go new file mode 100644 index 000000000..f7db6536a --- /dev/null +++ b/pkg/secrets/source_config_lock_test.go @@ -0,0 +1,57 @@ +package secrets + +import ( + "fmt" + "sync" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/require" +) + +// TestSaveSourceConfigsSerializesConcurrentWriters is a regression test for a +// race where concurrent `devsy secrets source add`/`remove` invocations could +// interleave their read-modify-write of secret-sources.yaml with no +// cross-process locking, risking a corrupted or torn write. SaveSourceConfigs +// now holds the same acquireFlock lock used elsewhere in this package (e.g. +// the local secret store) across its load-modify-write sequence, so +// concurrent writers are fully serialized: every write is exactly one +// caller's complete, well-formed source list, never an interleaved mix. +func TestSaveSourceConfigsSerializesConcurrentWriters(t *testing.T) { + devsyConfig := &config.Config{ + DefaultContext: "default", + Origin: t.TempDir() + "/config.yaml", + } + + const writers = 20 + var wg sync.WaitGroup + errs := make([]error, writers) + wantSets := make([][]SourceConfig, writers) + for i := range writers { + wantSets[i] = []SourceConfig{{ + Name: fmt.Sprintf("source-%d", i), + Type: SOPSFormatter, + Path: "secrets.enc.yaml", + }} + } + for i := range writers { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = SaveSourceConfigs(devsyConfig, wantSets[i]) + }(i) + } + wg.Wait() + + for _, err := range errs { + require.NoError(t, err) + } + + // Whichever writer finishes last determines the final state; the file + // must equal exactly one writer's complete list, never a corrupted + // interleaving of two or more. + final, err := LoadSourceConfigs(devsyConfig) + require.NoError(t, err) + require.Len(t, final, 1) + require.Contains(t, wantSets, final) +} diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 93339dfd6..271dc5ea7 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -89,8 +89,9 @@ devsy secret source add sops project ./secrets.enc.yaml ``` Devsy validates that the file can be decrypted before saving the source. The -configuration stores the source name, type, and file path only. Decrypted -values are not copied into the keyring or Devsy's `secrets.enc` file. +configuration stores the source name, type, file path, and (when set via +`--format`) the document format override only. Decrypted values are not +copied into the keyring or Devsy's `secrets.enc` file. List or remove locally registered sources with: From 2323357ce8445455db6f9110db1d2726e4bfc934 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 6 Sep 2026 15:12:02 -0600 Subject: [PATCH 14/17] fix(up): fix inspection and error cleanup - pkg/git: allow leading slash in subpath inspection and perform blobless full history clone when commit hash is specified to support short commit SHA checkouts. - pkg/secrets: allow leading slash in project secret source paths while continuing to disallow directory escapes. - cmd/workspace/up: clean up newly created workspace if secret discovery or provider check fails during prepareClient. - e2e/tests/up: ensure SOPS test source registration is idempotent. --- cmd/workspace/up/up_client.go | 15 ++++++++++++++ e2e/tests/up/sops.go | 6 ++++-- pkg/git/inspect.go | 33 +++++++++++++++++++++++------- pkg/git/inspect_test.go | 2 +- pkg/secrets/project_config.go | 4 +--- pkg/secrets/project_config_test.go | 6 +++++- 6 files changed, 52 insertions(+), 14 deletions(-) diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 822f9aa5c..008c3da8f 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -12,6 +12,7 @@ import ( "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/file" "github.com/devsy-org/devsy/pkg/log" options2 "github.com/devsy-org/devsy/pkg/options" provider2 "github.com/devsy-org/devsy/pkg/provider" @@ -82,6 +83,14 @@ func (cmd *UpCmd) prepareClient( cmd.resolveSSHConfig(devsyConfig) args = cmd.ensureArgsForFromSnapshot(args) + existed := false + if cmd.ID != "" { + existed = workspace2.Exists(ctx, devsyConfig, nil, cmd.ID, cmd.Owner) != "" + } else if len(args) > 0 { + _, name := file.IsLocalDir(args[0]) + existed = workspace2.Exists(ctx, devsyConfig, nil, workspace2.ToID(name), cmd.Owner) != "" + } + log.Debugf("up: resolving workspace with cmd.IDE=%q ide-launch=%q", cmd.IDE, cmd.IDELaunch) client, err := workspace2.Resolve( ctx, @@ -99,10 +108,16 @@ func (cmd *UpCmd) prepareClient( // client's resolved WorkspaceConfig().Source, not the possibly-nil // source parsed above. if err := cmd.prepareResolvedWorkspaceSecrets(ctx, devsyConfig, client); err != nil { + if !existed { + _ = client.Delete(ctx, client2.DeleteOptions{Force: true, IgnoreNotFound: true}) + } return nil, err } if err := cmd.checkProviderUpdate(ctx, devsyConfig, client); err != nil { + if !existed { + _ = client.Delete(ctx, client2.DeleteOptions{Force: true, IgnoreNotFound: true}) + } return nil, err } return client, nil diff --git a/e2e/tests/up/sops.go b/e2e/tests/up/sops.go index f2b62aa6c..e4869485c 100644 --- a/e2e/tests/up/sops.go +++ b/e2e/tests/up/sops.go @@ -14,6 +14,7 @@ import ( ) const ( + sourceSubCmd = "source" sopsE2EPlaintext = "SUPER_SECRET_TEST_VALUE_7B91" sopsE2EMounted = "mounted-value-77" sopsE2EAgeIdentity = "AGE-SECRET-KEY-12UWYSAH2MRDQ5K4EWC4253PDTCSCS32Y5EFQ8TEN2SL3QYU2GN2SG88CZX" // gitleaks:allow @@ -163,12 +164,13 @@ func unsetSOPSEnv(name string) { } func registerSOPSSource(ctx context.Context, dtc *dockerTestContext, name, filePath string) { + _, _ = dtc.f.ExecCommandOutput(ctx, []string{secretCmd, sourceSubCmd, "remove", name}) _, err := dtc.f.ExecCommandOutput( ctx, - []string{secretCmd, "source", "add", "sops", name, filePath}, + []string{secretCmd, sourceSubCmd, "add", "sops", name, filePath}, ) framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { - _, _ = dtc.f.ExecCommandOutput(ctx, []string{secretCmd, "source", "remove", name}) + _, _ = dtc.f.ExecCommandOutput(ctx, []string{secretCmd, sourceSubCmd, "remove", name}) }) } diff --git a/pkg/git/inspect.go b/pkg/git/inspect.go index 8ac6c974e..d3b5b861d 100644 --- a/pkg/git/inspect.go +++ b/pkg/git/inspect.go @@ -67,7 +67,10 @@ func cloneInspectionRepo( env []string, ) (*Repo, error) { target := filepath.Join(root, "repo") - args := []string{"clone", bloblessCloneFilter, "--no-checkout", "--depth=1"} + args := []string{"clone", bloblessCloneFilter, "--no-checkout"} + if info.Commit == "" { + args = append(args, "--depth=1") + } if info.Branch != "" { args = append(args, "--branch", info.Branch) } @@ -112,10 +115,28 @@ func fetchInspectionPR( } func fetchInspectionCommit(ctx context.Context, repo *Repo, commit string) (string, error) { - if _, err := repo.run(ctx, "fetch", "--depth=1", "origin", commit); err != nil { - return "", fmt.Errorf("fetch commit %q: %w", commit, err) + if _, err := repo.run(ctx, "cat-file", "-e", commit+"^{commit}"); err == nil { + return commit, nil + } + if _, err := repo.run(ctx, "fetch", "--depth=1", "origin", commit); err == nil { + return "FETCH_HEAD", nil + } + if _, err := repo.run( + ctx, + "fetch", + "origin", + "+refs/heads/*:refs/remotes/origin/*", + ); err == nil { + if _, err := repo.run(ctx, "cat-file", "-e", commit+"^{commit}"); err == nil { + return commit, nil + } } - return "FETCH_HEAD", nil + if _, err := repo.run(ctx, "fetch", "--unshallow", "origin"); err == nil { + if _, err := repo.run(ctx, "cat-file", "-e", commit+"^{commit}"); err == nil { + return commit, nil + } + } + return "", fmt.Errorf("fetch commit %q: commit not found in remote repository", commit) } // ReadFile returns the bytes for a path relative to the selected subpath @@ -152,12 +173,10 @@ func (i *Inspection) ReadFile(ctx context.Context, filePath string) ([]byte, err // another repository-relative path. func cleanRepoRelativePath(kind, value string) (string, error) { value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + value = strings.TrimPrefix(value, "/") if value == "" { return "", nil } - if strings.HasPrefix(value, "/") { - return "", fmt.Errorf("git %s %q must be relative to the repository root", kind, value) - } clean := path.Clean(value) if clean == "." { return "", nil diff --git a/pkg/git/inspect_test.go b/pkg/git/inspect_test.go index 3392e671c..ed92301a8 100644 --- a/pkg/git/inspect_test.go +++ b/pkg/git/inspect_test.go @@ -51,7 +51,7 @@ func TestCleanInspectionSubPath(t *testing.T) { {name: "dot", value: ".", want: ""}, {name: "simple", value: testSubPath, want: testSubPath}, {name: "trailing slash", value: testSubPath + "/", want: testSubPath}, - {name: "absolute rejected", value: "/apps/bar", wantErr: true}, + {name: "leading slash stripped", value: "/apps/bar", want: "apps/bar"}, {name: "parent escape rejected", value: "../bar", wantErr: true}, {name: "parent only rejected", value: "..", wantErr: true}, } { diff --git a/pkg/secrets/project_config.go b/pkg/secrets/project_config.go index c9cfbbc9b..d410308e0 100644 --- a/pkg/secrets/project_config.go +++ b/pkg/secrets/project_config.go @@ -100,12 +100,10 @@ func validateProjectSecret(value string, sources map[string]struct{}) error { // never be able to read arbitrary host files. func CleanProjectSourcePath(value string) (string, error) { value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + value = strings.TrimPrefix(value, "/") if value == "" { return "", fmt.Errorf("source path must not be empty") } - if strings.HasPrefix(value, "/") { - return "", fmt.Errorf("source path %q must be relative to the repository root", value) - } clean := path.Clean(value) if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { return "", fmt.Errorf("source path %q escapes the repository root", value) diff --git a/pkg/secrets/project_config_test.go b/pkg/secrets/project_config_test.go index d7eb2b8f4..47c312e0f 100644 --- a/pkg/secrets/project_config_test.go +++ b/pkg/secrets/project_config_test.go @@ -34,7 +34,11 @@ func TestCleanProjectSourcePath(t *testing.T) { require.NoError(t, err) require.Equal(t, "config/secrets.enc.yaml", clean) - for _, bad := range []string{"", "/etc/passwd", "../secret", "a/../../secret"} { + cleanAbs, err := CleanProjectSourcePath("/config/secrets.enc.yaml") + require.NoError(t, err) + require.Equal(t, "config/secrets.enc.yaml", cleanAbs) + + for _, bad := range []string{"", "../secret", "a/../../secret"} { _, err := CleanProjectSourcePath(bad) require.Error(t, err, bad) } From ce09e8acc49505bccf48a18c43f6c0a525dc1872 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 6 Sep 2026 16:19:20 -0600 Subject: [PATCH 15/17] fix(secrets): serialize source config and fix up - pkg/secrets: add ModifySourceConfigs to hold the file lock across loading, mutating, and writing external source configurations. Update persistSOPSSource and removeSourceConfig to mutate atomically without calling SaveSourceConfigs while the lock is held. - cmd/workspace/up: synthesize args from --source when positional args are omitted, ensuring proper existed check and workspace resolution. - cmd/workspace/up: preserve existing workspaces during interactive resolution when subsequent secret discovery fails. --- cmd/secrets/source.go | 44 ++++++------ cmd/workspace/up/project_secrets_test.go | 90 +++++++++++++++++++++++- cmd/workspace/up/up.go | 7 ++ cmd/workspace/up/up_client.go | 34 ++++++--- cmd/workspace/up/up_client_test.go | 26 +++++++ pkg/secrets/source_config.go | 25 ++++++- pkg/secrets/source_config_lock_test.go | 40 ++++++++++- 7 files changed, 227 insertions(+), 39 deletions(-) diff --git a/cmd/secrets/source.go b/cmd/secrets/source.go index 4b5b612b0..2a5206831 100644 --- a/cmd/secrets/source.go +++ b/cmd/secrets/source.go @@ -107,20 +107,17 @@ func resolveSOPSFile(filePath string) (string, error) { } func persistSOPSSource(devsyConfig *config.Config, name, filePath, format string) error { - sources, err := secrets2.LoadSourceConfigs(devsyConfig) - if err != nil { - return err - } - sources, err = secrets2.AddSourceConfig(sources, secrets2.SourceConfig{ - Name: name, - Type: secrets2.SOPSFormatter, - Path: filePath, - Format: format, - }) - if err != nil { - return err - } - return secrets2.SaveSourceConfigs(devsyConfig, sources) + return secrets2.ModifySourceConfigs( + devsyConfig, + func(sources []secrets2.SourceConfig) ([]secrets2.SourceConfig, error) { + return secrets2.AddSourceConfig(sources, secrets2.SourceConfig{ + Name: name, + Type: secrets2.SOPSFormatter, + Path: filePath, + Format: format, + }) + }, + ) } func newSourceListCmd(globalFlags *flags.GlobalFlags) *cobra.Command { @@ -211,13 +208,14 @@ func attachedSourceReferences(devsyConfig *config.Config, name string) []string } func removeSourceConfig(devsyConfig *config.Config, name string) error { - sources, err := secrets2.LoadSourceConfigs(devsyConfig) - if err != nil { - return err - } - sources, removed := secrets2.RemoveSourceConfig(sources, name) - if !removed { - return fmt.Errorf("secret source %q is not configured", name) - } - return secrets2.SaveSourceConfigs(devsyConfig, sources) + return secrets2.ModifySourceConfigs( + devsyConfig, + func(sources []secrets2.SourceConfig) ([]secrets2.SourceConfig, error) { + sources, removed := secrets2.RemoveSourceConfig(sources, name) + if !removed { + return nil, fmt.Errorf("secret source %q is not configured", name) + } + return sources, nil + }, + ) } diff --git a/cmd/workspace/up/project_secrets_test.go b/cmd/workspace/up/project_secrets_test.go index 78c65e8c9..5ac505013 100644 --- a/cmd/workspace/up/project_secrets_test.go +++ b/cmd/workspace/up/project_secrets_test.go @@ -6,15 +6,19 @@ import ( "path/filepath" "testing" + "github.com/devsy-org/devsy/cmd/flags" client2 "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/workspace" "github.com/stretchr/testify/require" ) // fakeWorkspaceClient is a minimal client2.BaseWorkspaceClient used to // exercise prepareResolvedWorkspaceSecrets without a real provider. type fakeWorkspaceClient struct { - config *provider.Workspace + config *provider.Workspace + deleted bool } func (f *fakeWorkspaceClient) Provider() string { return "" } @@ -29,8 +33,11 @@ func (f *fakeWorkspaceClient) Status( ) (client2.Status, error) { return client2.StatusRunning, nil } -func (f *fakeWorkspaceClient) Stop(context.Context, client2.StopOptions) error { return nil } -func (f *fakeWorkspaceClient) Delete(context.Context, client2.DeleteOptions) error { return nil } +func (f *fakeWorkspaceClient) Stop(context.Context, client2.StopOptions) error { return nil } +func (f *fakeWorkspaceClient) Delete(context.Context, client2.DeleteOptions) error { + f.deleted = true + return nil +} func (f *fakeWorkspaceClient) Workspace() string { return "" } @@ -94,3 +101,80 @@ func TestPrepareResolvedWorkspaceSecrets_DiscoversLocalProjectSecrets(t *testing require.Contains(t, cmd.SecretsEnv, "SOPS_E2E_SECRET="+testProjectSecretsPlaintext) } + +func newFailingProjectWorkspace(t *testing.T) *fakeWorkspaceClient { + t.Helper() + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, ".devsy"), 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(root, ".devsy", "config.yaml"), + []byte("secretSources:\n"+ + " - name: project\n"+ + " type: sops\n"+ + " path: nonexistent.enc.yaml\n"+ + "secrets:\n"+ + " - sops:project/MISSING\n"), + 0o600, + )) + return &fakeWorkspaceClient{ + config: &provider.Workspace{ + ID: "my-test-workspace", + Source: provider.WorkspaceSource{LocalFolder: root}, + }, + } +} + +// TestPrepareClient_InteractiveResolutionPreservesExistingWorkspaceOnError verifies +// that when a workspace is resolved without positional arguments (interactive resolution), +// any subsequent failure in prepareResolvedWorkspaceSecrets does not force-delete the +// user's selected existing workspace. +func TestPrepareClient_InteractiveResolutionPreservesExistingWorkspaceOnError(t *testing.T) { + client := newFailingProjectWorkspace(t) + cmd := &UpCmd{ + GlobalFlags: &flags.GlobalFlags{}, + resolveWorkspace: func( + context.Context, + *config.Config, + workspace.ResolveParams, + ) (client2.BaseWorkspaceClient, error) { + return client, nil + }, + } + + _, err := cmd.prepareClient(context.Background(), testConfig(), nil) + require.Error(t, err) + require.False( + t, + client.deleted, + "interactively resolved existing workspace must not be deleted on secret error", + ) +} + +// TestPrepareClient_NewWorkspaceCleanedUpOnError verifies that when a newly created +// workspace fails during secret preparation, it is cleanly removed so that no +// orphaned workspace folder or record remains. +func TestPrepareClient_NewWorkspaceCleanedUpOnError(t *testing.T) { + client := newFailingProjectWorkspace(t) + cmd := &UpCmd{ + GlobalFlags: &flags.GlobalFlags{}, + resolveWorkspace: func( + context.Context, + *config.Config, + workspace.ResolveParams, + ) (client2.BaseWorkspaceClient, error) { + return client, nil + }, + } + + _, err := cmd.prepareClient( + context.Background(), + testConfig(), + []string{filepath.Join(t.TempDir(), "new-workspace")}, + ) + require.Error(t, err) + require.True( + t, + client.deleted, + "newly created workspace must be cleaned up on secret error", + ) +} diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 8407cc39f..66e73332f 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -65,6 +65,13 @@ type UpCmd struct { Out io.Writer // Set at the start of Run; nil until then. statusReporter status.Reporter + + // resolveWorkspace overrides workspace.Resolve; nil falls back to workspace.Resolve. + resolveWorkspace func( + ctx context.Context, + devsyConfig *config.Config, + params workspace.ResolveParams, + ) (client2.BaseWorkspaceClient, error) } // Options is the structured input form of the up command. diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 008c3da8f..8a732cf33 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -81,18 +81,25 @@ func (cmd *UpCmd) prepareClient( } cmd.resolveSSHConfig(devsyConfig) - args = cmd.ensureArgsForFromSnapshot(args) + args = cmd.ensureArgs(args) - existed := false - if cmd.ID != "" { + var existed bool + switch { + case cmd.ID != "": existed = workspace2.Exists(ctx, devsyConfig, nil, cmd.ID, cmd.Owner) != "" - } else if len(args) > 0 { + case len(args) > 0: _, name := file.IsLocalDir(args[0]) existed = workspace2.Exists(ctx, devsyConfig, nil, workspace2.ToID(name), cmd.Owner) != "" + default: + existed = true } log.Debugf("up: resolving workspace with cmd.IDE=%q ide-launch=%q", cmd.IDE, cmd.IDELaunch) - client, err := workspace2.Resolve( + resolver := cmd.resolveWorkspace + if resolver == nil { + resolver = workspace2.Resolve + } + client, err := resolver( ctx, devsyConfig, cmd.resolveParams(args, source, devsyConfig), @@ -174,15 +181,24 @@ func (cmd *UpCmd) checkProviderUpdate( return workspace2.CheckProviderUpdate(ctx, devsyConfig, proInstance) } -// ensureArgsForFromSnapshot returns args unchanged unless --from-snapshot is +// ensureArgs returns args unchanged unless either --from-snapshot or --source is // set and args is empty, in which case it synthesizes a placeholder arg. -func (cmd *UpCmd) ensureArgsForFromSnapshot(args []string) []string { - if cmd.FromSnapshot != "" && len(args) == 0 { - return []string{cmd.FromSnapshot} +func (cmd *UpCmd) ensureArgs(args []string) []string { + if len(args) == 0 { + if cmd.FromSnapshot != "" { + return []string{cmd.FromSnapshot} + } + if cmd.Source != "" { + return []string{cmd.Source} + } } return args } +func (cmd *UpCmd) ensureArgsForFromSnapshot(args []string) []string { + return cmd.ensureArgs(args) +} + func (cmd *UpCmd) resolveParams( args []string, source *provider2.WorkspaceSource, devsyConfig *config.Config, ) workspace2.ResolveParams { diff --git a/cmd/workspace/up/up_client_test.go b/cmd/workspace/up/up_client_test.go index 2edeaa72e..160065e16 100644 --- a/cmd/workspace/up/up_client_test.go +++ b/cmd/workspace/up/up_client_test.go @@ -37,3 +37,29 @@ func TestEnsureArgsForFromSnapshot_NoOpWithoutFromSnapshot(t *testing.T) { t.Errorf("args = %v, want nil (no --from-snapshot, nothing to synthesize)", args) } } + +func TestEnsureArgs_SynthesizesPlaceholderWhenSourceProvidedAndArgsEmpty(t *testing.T) { + cmd := &UpCmd{} + cmd.Source = "https://github.com/devsy-org/devsy" + + args := cmd.ensureArgs(nil) + + if len(args) != 1 || args[0] != cmd.Source { + t.Errorf( + "args = %v, want a single placeholder arg [https://github.com/devsy-org/devsy]", + args, + ) + } +} + +func TestEnsureArgs_PrefersFromSnapshotOverSourceWhenBothEmptyArgs(t *testing.T) { + cmd := &UpCmd{} + cmd.FromSnapshot = "ghcr.io/acme/snapshots:my-ws-20260731150405-abcxyz" + cmd.Source = "https://github.com/devsy-org/devsy" + + args := cmd.ensureArgs(nil) + + if len(args) != 1 || args[0] != cmd.FromSnapshot { + t.Errorf("args = %v, want snapshot arg", args) + } +} diff --git a/pkg/secrets/source_config.go b/pkg/secrets/source_config.go index eccd3e74b..dc030f944 100644 --- a/pkg/secrets/source_config.go +++ b/pkg/secrets/source_config.go @@ -52,8 +52,12 @@ func LoadSourceConfigs(devsyConfig *config.Config) ([]SourceConfig, error) { return append([]SourceConfig(nil), items...), nil } -// SaveSourceConfigs writes external source metadata for the active context. -func SaveSourceConfigs(devsyConfig *config.Config, sources []SourceConfig) error { +// ModifySourceConfigs serializes external source modifications across processes +// by holding the secret-sources lock while loading, mutating, and writing configuration. +func ModifySourceConfigs( + devsyConfig *config.Config, + mutate func(sources []SourceConfig) ([]SourceConfig, error), +) error { if devsyConfig == nil { return fmt.Errorf("devsy config is nil") } @@ -70,10 +74,25 @@ func SaveSourceConfigs(devsyConfig *config.Config, sources []SourceConfig) error if err != nil { return err } - setContextSources(®istry, devsyConfig.DefaultContext, sources) + var current []SourceConfig + if registry.Contexts != nil { + current = registry.Contexts[devsyConfig.DefaultContext] + } + updated, err := mutate(append([]SourceConfig(nil), current...)) + if err != nil { + return err + } + setContextSources(®istry, devsyConfig.DefaultContext, updated) return writeSourceRegistry(filePath, registry) } +// SaveSourceConfigs writes external source metadata for the active context. +func SaveSourceConfigs(devsyConfig *config.Config, sources []SourceConfig) error { + return ModifySourceConfigs(devsyConfig, func(_ []SourceConfig) ([]SourceConfig, error) { + return sources, nil + }) +} + func loadSourceRegistry(filePath string) (sourceRegistryFile, error) { registry := sourceRegistryFile{Contexts: map[string][]SourceConfig{}} // #nosec G304 -- filePath is generated by sourceConfigPath under Devsy's config directory. diff --git a/pkg/secrets/source_config_lock_test.go b/pkg/secrets/source_config_lock_test.go index f7db6536a..ae13963dd 100644 --- a/pkg/secrets/source_config_lock_test.go +++ b/pkg/secrets/source_config_lock_test.go @@ -17,6 +17,8 @@ import ( // the local secret store) across its load-modify-write sequence, so // concurrent writers are fully serialized: every write is exactly one // caller's complete, well-formed source list, never an interleaved mix. +const testSecretSourcePath = "test-secrets.enc.yaml" + func TestSaveSourceConfigsSerializesConcurrentWriters(t *testing.T) { devsyConfig := &config.Config{ DefaultContext: "default", @@ -31,7 +33,7 @@ func TestSaveSourceConfigsSerializesConcurrentWriters(t *testing.T) { wantSets[i] = []SourceConfig{{ Name: fmt.Sprintf("source-%d", i), Type: SOPSFormatter, - Path: "secrets.enc.yaml", + Path: testSecretSourcePath, }} } for i := range writers { @@ -55,3 +57,39 @@ func TestSaveSourceConfigsSerializesConcurrentWriters(t *testing.T) { require.Len(t, final, 1) require.Contains(t, wantSets, final) } + +func TestModifySourceConfigsSerializesConcurrentMutations(t *testing.T) { + devsyConfig := &config.Config{ + DefaultContext: "default", + Origin: t.TempDir() + "/config.yaml", + } + + const writers = 20 + var wg sync.WaitGroup + errs := make([]error, writers) + for i := range writers { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = ModifySourceConfigs( + devsyConfig, + func(sources []SourceConfig) ([]SourceConfig, error) { + return AddSourceConfig(sources, SourceConfig{ + Name: fmt.Sprintf("source-%d", i), + Type: SOPSFormatter, + Path: testSecretSourcePath, + }) + }, + ) + }(i) + } + wg.Wait() + + for _, err := range errs { + require.NoError(t, err) + } + + final, err := LoadSourceConfigs(devsyConfig) + require.NoError(t, err) + require.Len(t, final, writers) +} From b337a8bb61bec500216aef772fbe28f69087bc74 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 6 Sep 2026 18:05:06 -0500 Subject: [PATCH 16/17] docs: update contents --- cmd/secrets/secrets.go | 2 +- cmd/workspace/up/project_secrets_test.go | 21 ---- cmd/workspace/up/up.go | 5 +- cmd/workspace/up/up_client.go | 21 +--- pkg/git/inspect.go | 13 +-- pkg/git/inspect_test.go | 8 -- pkg/secrets/local_source.go | 2 +- pkg/secrets/project_config.go | 5 +- pkg/secrets/sops_source.go | 6 +- pkg/secrets/source.go | 3 +- pkg/secrets/source_config_lock_test.go | 11 -- pkg/secrets/source_factory.go | 2 +- .../docs/developing-in-workspaces/secrets.mdx | 105 +++--------------- 13 files changed, 32 insertions(+), 172 deletions(-) diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go index 64a5e1ad9..d6c12cfe8 100644 --- a/cmd/secrets/secrets.go +++ b/cmd/secrets/secrets.go @@ -13,7 +13,7 @@ func NewSecretsCmd(flags *flags.GlobalFlags) *cobra.Command { Short: "Manage secrets and external secret sources", Long: `Manage secrets and external secret sources used by Devsy workspaces. -Devsy-managed secret values are kept in the OS keyring (macOS Keychain, +Managed secret values are kept in the OS keyring (macOS Keychain, Windows Credential Manager, or libsecret) when available, and in an age-encrypted file otherwise. External sources such as SOPS remain owned by their encrypted source file and are resolved only when needed.`, diff --git a/cmd/workspace/up/project_secrets_test.go b/cmd/workspace/up/project_secrets_test.go index 5ac505013..49b42e345 100644 --- a/cmd/workspace/up/project_secrets_test.go +++ b/cmd/workspace/up/project_secrets_test.go @@ -14,8 +14,6 @@ import ( "github.com/stretchr/testify/require" ) -// fakeWorkspaceClient is a minimal client2.BaseWorkspaceClient used to -// exercise prepareResolvedWorkspaceSecrets without a real provider. type fakeWorkspaceClient struct { config *provider.Workspace deleted bool @@ -52,11 +50,6 @@ const ( testProjectEncryptedFixture = "testdata/sops-project-secrets.enc.yaml" ) -// newTestProjectWorkspace writes a .devsy/config.yaml declaring a repository- -// owned SOPS source plus an attached secret, and the matching encrypted -// document, under a fresh temp directory. It returns a fake client whose -// WorkspaceConfig().Source.LocalFolder points at that directory, mirroring -// what workspace2.Resolve produces for a local-folder positional argument. func newTestProjectWorkspace(t *testing.T) *fakeWorkspaceClient { t.Helper() root := t.TempDir() @@ -84,13 +77,6 @@ func newTestProjectWorkspace(t *testing.T) *fakeWorkspaceClient { } } -// TestPrepareResolvedWorkspaceSecrets_DiscoversLocalProjectSecrets is a -// regression test for a bug where repository-owned SOPS secrets declared in -// a local project's .devsy/config.yaml were never discovered for ordinary -// `devsy up ` CLI invocations: project discovery ran against the -// source returned by parseWorkspaceSource (populated only by --source/ -// --from-snapshot), not the workspace source workspace2.Resolve derives -// from a positional argument. func TestPrepareResolvedWorkspaceSecrets_DiscoversLocalProjectSecrets(t *testing.T) { t.Setenv("SOPS_AGE_KEY", testProjectSecretsAgeIdentity) client := newTestProjectWorkspace(t) @@ -124,10 +110,6 @@ func newFailingProjectWorkspace(t *testing.T) *fakeWorkspaceClient { } } -// TestPrepareClient_InteractiveResolutionPreservesExistingWorkspaceOnError verifies -// that when a workspace is resolved without positional arguments (interactive resolution), -// any subsequent failure in prepareResolvedWorkspaceSecrets does not force-delete the -// user's selected existing workspace. func TestPrepareClient_InteractiveResolutionPreservesExistingWorkspaceOnError(t *testing.T) { client := newFailingProjectWorkspace(t) cmd := &UpCmd{ @@ -150,9 +132,6 @@ func TestPrepareClient_InteractiveResolutionPreservesExistingWorkspaceOnError(t ) } -// TestPrepareClient_NewWorkspaceCleanedUpOnError verifies that when a newly created -// workspace fails during secret preparation, it is cleanly removed so that no -// orphaned workspace folder or record remains. func TestPrepareClient_NewWorkspaceCleanedUpOnError(t *testing.T) { client := newFailingProjectWorkspace(t) cmd := &UpCmd{ diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 66e73332f..93d92941c 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -136,10 +136,7 @@ func RunHeadless( ) if cfg := client.WorkspaceConfig(); cfg != nil { // Bootstrap credentials must be resolved before repository-owned - // secret sources are discovered, mirroring - // prepareResolvedWorkspaceSecrets/prepareBootstrapGitToken, so that - // authenticated remote inspection (and any later use of - // cmd.GitToken) works from the headless path too. + // secret sources are discovered if err := cmd.prepareBootstrapGitToken(ctx, opts.DevsyConfig, &cfg.Source); err != nil { return nil, err } diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 8a732cf33..b67872033 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -73,9 +73,6 @@ func (cmd *UpCmd) prepareClient( if err != nil { return nil, err } - // Bootstrap credentials are resolved exclusively from sources that are - // available before repository acquisition. Repository-owned sources are - // deliberately not registered yet, which prevents circular clone auth. if err := cmd.prepareBootstrapGitToken(ctx, devsyConfig, source); err != nil { return nil, err } @@ -94,7 +91,7 @@ func (cmd *UpCmd) prepareClient( existed = true } - log.Debugf("up: resolving workspace with cmd.IDE=%q ide-launch=%q", cmd.IDE, cmd.IDELaunch) + log.Debugf("resolving workspace with ide=%q launch=%q", cmd.IDE, cmd.IDELaunch) resolver := cmd.resolveWorkspace if resolver == nil { resolver = workspace2.Resolve @@ -108,12 +105,6 @@ func (cmd *UpCmd) prepareClient( return nil, err } - // The workspace source (local folder, git repository, or image) is only - // fully known once Resolve has determined it, e.g. from a positional - // workspace argument rather than --source/--from-snapshot. Repository- - // owned secret discovery must therefore happen after Resolve, using the - // client's resolved WorkspaceConfig().Source, not the possibly-nil - // source parsed above. if err := cmd.prepareResolvedWorkspaceSecrets(ctx, devsyConfig, client); err != nil { if !existed { _ = client.Delete(ctx, client2.DeleteOptions{Force: true, IgnoreNotFound: true}) @@ -140,18 +131,14 @@ func (cmd *UpCmd) prepareClientEnvironment( } if cmd.Platform.Enabled { log.Debug("running in platform mode") - log.Debug("using error output stream") config.MergeContextOptions(devsyConfig.Current(), os.Environ()) } return cmd.validateFromSnapshot(ctx, args) } // prepareResolvedWorkspaceSecrets discovers repository-owned project secrets -// (e.g. SOPS sources declared in .devsy/config.yaml) using the workspace's -// fully resolved source and merges them with attached/explicit secrets. -// This must run after workspace2.Resolve, since a positional workspace -// argument's local-folder/git-repository/image classification is only known -// once Resolve has determined client.WorkspaceConfig().Source. +// using the workspace's resolved source and merges them with attached/explicit +// secrets. This must run after workspace2.Resolve. func (cmd *UpCmd) prepareResolvedWorkspaceSecrets( ctx context.Context, devsyConfig *config.Config, @@ -340,7 +327,7 @@ func (cmd *UpCmd) applyLifecycleSecrets( return nil } -// applyEnvVars resolves --env entries from the local Devsy store. External +// applyEnvVars resolves --env entries from the local store. External // sensitive sources intentionally use --secret instead: WorkspaceEnv rides in // the setup argv and is process-list visible. func (cmd *UpCmd) applyEnvVars(ctx context.Context, resolver *secrets.Resolver) error { diff --git a/pkg/git/inspect.go b/pkg/git/inspect.go index d3b5b861d..466c61c5b 100644 --- a/pkg/git/inspect.go +++ b/pkg/git/inspect.go @@ -19,15 +19,12 @@ const ( var ErrRevisionPathNotFound = errors.New("path not found in git revision") -// Inspection is a lightweight, blobless, no-checkout clone used to inspect a -// small number of repository-owned configuration files before workspace build. +// Inspection is a blobless, no-checkout clone used to inspect a +// repository-owned configuration files before workspace build. type Inspection struct { - repo *Repo - rev string - root string - // subPath is the repository-relative directory that ReadFile treats as - // the project root, mirroring info.SubPath (the @subpath: selector). - // Empty means the repository root. + repo *Repo + rev string + root string subPath string } diff --git a/pkg/git/inspect_test.go b/pkg/git/inspect_test.go index ed92301a8..0d2e43011 100644 --- a/pkg/git/inspect_test.go +++ b/pkg/git/inspect_test.go @@ -9,10 +9,6 @@ import ( const testSubPath = "apps/foo" -// TestInspectionReadFileUsesSubPath verifies that ReadFile resolves paths -// relative to the selected @subpath: project root instead of the repository -// root, so repository-owned config (e.g. .devsy/config.yaml) and SOPS source -// files inside a subproject are discovered correctly. func TestInspectionReadFileUsesSubPath(t *testing.T) { runner := &fakeRunner{stdout: []byte("secret-contents")} repo := At("/tmp/repo", WithRunner(runner)) @@ -67,10 +63,6 @@ func TestCleanInspectionSubPath(t *testing.T) { } } -// TestInspectionReadFileRejectsPathEscape is a regression test ensuring -// ReadFile itself rejects a path that would escape the repository root (or -// the selected subpath) once cleaned and joined, rather than relying solely -// on callers to pre-validate the path. func TestInspectionReadFileRejectsPathEscape(t *testing.T) { runner := &fakeRunner{stdout: []byte("secret-contents")} repo := At("/tmp/repo", WithRunner(runner)) diff --git a/pkg/secrets/local_source.go b/pkg/secrets/local_source.go index 0187299ae..5d8de2549 100644 --- a/pkg/secrets/local_source.go +++ b/pkg/secrets/local_source.go @@ -5,7 +5,7 @@ import ( "fmt" ) -// LocalSource adapts the existing Devsy Store to the generic source interface. +// LocalSource adapts the Devsy Store to the generic source interface. type LocalSource struct { store Store context string diff --git a/pkg/secrets/project_config.go b/pkg/secrets/project_config.go index d410308e0..b44fbc8ab 100644 --- a/pkg/secrets/project_config.go +++ b/pkg/secrets/project_config.go @@ -14,7 +14,7 @@ import ( const ProjectConfigPath = ".devsy/config.yaml" // ProjectConfig is the repository-owned subset of Devsy configuration used by -// secret discovery. It is intentionally declarative and does not execute code. +// secret discovery. type ProjectConfig struct { SecretSources []SourceConfig `json:"secretSources,omitempty" yaml:"secretSources,omitempty"` Secrets []string `json:"secrets,omitempty" yaml:"secrets,omitempty"` @@ -96,8 +96,7 @@ func validateProjectSecret(value string, sources map[string]struct{}) error { } // CleanProjectSourcePath validates a repository-controlled source path and -// returns a normalized repository-relative slash path. Repository config must -// never be able to read arbitrary host files. +// returns a normalized repository-relative slash path. func CleanProjectSourcePath(value string) (string, error) { value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) value = strings.TrimPrefix(value, "/") diff --git a/pkg/secrets/sops_source.go b/pkg/secrets/sops_source.go index 7e83110ee..4f362f194 100644 --- a/pkg/secrets/sops_source.go +++ b/pkg/secrets/sops_source.go @@ -26,7 +26,7 @@ const ( // SOPSSource resolves values from one SOPS-encrypted document. A source is // command-scoped: decrypted values are cached in memory for the lifetime of the -// source instance and are never persisted by Devsy. +// source instance. type SOPSSource struct { name string path string @@ -120,9 +120,7 @@ func (s *SOPSSource) decrypt(format string) ([]byte, error) { plaintext, err = decrypt.File(s.path, format) } if err != nil { - // Do not include encrypted/decrypted document contents in the error. The - // upstream error is retained because it contains actionable key-provider - // information (for example, an unmatched age recipient or KMS failure). + // Error must not include encrypted/decrypted document contents. return nil, fmt.Errorf("failed to decrypt SOPS source %q: %w", s.name, err) } return plaintext, nil diff --git a/pkg/secrets/source.go b/pkg/secrets/source.go index dbe82b402..0ba7f7b0b 100644 --- a/pkg/secrets/source.go +++ b/pkg/secrets/source.go @@ -10,8 +10,7 @@ type ResolvedSecret struct { Source string } -// Source resolves externally or locally owned secret values. Implementations -// must not persist values as a side effect of Get. +// Source resolves externally or locally owned secret values. type Source interface { Get(ctx context.Context, name string) (ResolvedSecret, error) } diff --git a/pkg/secrets/source_config_lock_test.go b/pkg/secrets/source_config_lock_test.go index ae13963dd..ff6cb7fe0 100644 --- a/pkg/secrets/source_config_lock_test.go +++ b/pkg/secrets/source_config_lock_test.go @@ -9,14 +9,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestSaveSourceConfigsSerializesConcurrentWriters is a regression test for a -// race where concurrent `devsy secrets source add`/`remove` invocations could -// interleave their read-modify-write of secret-sources.yaml with no -// cross-process locking, risking a corrupted or torn write. SaveSourceConfigs -// now holds the same acquireFlock lock used elsewhere in this package (e.g. -// the local secret store) across its load-modify-write sequence, so -// concurrent writers are fully serialized: every write is exactly one -// caller's complete, well-formed source list, never an interleaved mix. const testSecretSourcePath = "test-secrets.enc.yaml" func TestSaveSourceConfigsSerializesConcurrentWriters(t *testing.T) { @@ -49,9 +41,6 @@ func TestSaveSourceConfigsSerializesConcurrentWriters(t *testing.T) { require.NoError(t, err) } - // Whichever writer finishes last determines the final state; the file - // must equal exactly one writer's complete list, never a corrupted - // interleaving of two or more. final, err := LoadSourceConfigs(devsyConfig) require.NoError(t, err) require.Len(t, final, 1) diff --git a/pkg/secrets/source_factory.go b/pkg/secrets/source_factory.go index 6e611ea9d..66b991783 100644 --- a/pkg/secrets/source_factory.go +++ b/pkg/secrets/source_factory.go @@ -7,7 +7,7 @@ import ( ) // NewResolverForConfig constructs the local Devsy source plus all external -// sources registered in the active local context. Sources decrypt lazily. +// sources registered in the active local context. func NewResolverForConfig(devsyConfig *config.Config) (*Resolver, error) { if devsyConfig == nil { return nil, fmt.Errorf("devsy config is nil") diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 271dc5ea7..220e2f8cb 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -66,8 +66,8 @@ without making Devsy a second source of truth for that value. ### SOPS Devsy supports [SOPS](https://github.com/getsops/sops)-encrypted YAML, JSON, and -dotenv files. Devsy uses the SOPS Go implementation directly; installing a -`sops` executable is not required for workspace startup. +dotenv files. Devsy uses the SOPS Go implementation; installing a +`sops` executable is not required. SOPS source documents use flat top-level key/value pairs in the initial implementation: @@ -77,8 +77,6 @@ DATABASE_PASSWORD: ENC[...] API_TOKEN: ENC[...] ``` -Nested objects and arrays are not implicitly flattened into secret names. - ### Registering a Local SOPS Source If the encrypted file is already available on the machine running Devsy, add a @@ -105,8 +103,7 @@ it. ### Repository-Owned SOPS Sources -A repository can declare SOPS sources in `.devsy/config.yaml` so every developer -uses the repository's encrypted file as the source of truth. +A repository can declare SOPS sources in `.devsy/config.yaml`. ```text project/ @@ -143,10 +140,9 @@ and with a remote Git source: devsy workspace up https://github.com/acme/project ``` -For a remote source Devsy first acquires enough repository data to read the -exact requested Git revision, then reads `.devsy/config.yaml` and the referenced -encrypted SOPS files from that same revision. Runtime and build secrets are -resolved only after this repository discovery step. +For a remote source Devsy first acquires repository data to read the requested Git +revision, then reads `.devsy/config.yaml` and the referenced +encrypted SOPS files from that same revision. ### Bootstrap Authentication for Private Repositories @@ -166,10 +162,7 @@ local Devsy secret / Git credential / SSH agent ``` A repository-owned SOPS secret cannot authenticate the clone of the same -repository that contains it. That would create a dependency cycle: Devsy would -need the repository to read the token and the token to read the repository. -Devsy fails this configuration rather than retrying or falling back to another -secret source. +repository that contains it. `--git-token` may reference a Devsy-managed secret or another external source that is already locally available before repository acquisition. @@ -223,9 +216,7 @@ The value is available at: /run/secrets/tls.key ``` -The same provider-specific memory-backed mechanism used for Devsy-managed -secrets is used for external sources. Providers that cannot offer an in-memory -secret mount reject `type=mount` with an error. +Providers that cannot offer an in-memory secret mount reject `type=mount` with an error. ### Build Secrets @@ -261,15 +252,10 @@ devsy secret detach DB_PASSWORD devsy secret detach sops:project/API_TOKEN ``` -Devsy stores only the source-qualified reference for an external secret. It -does not copy the value into the local Devsy store. - -Repository-owned `.devsy/config.yaml` can declare project-specific automatic -bindings with its `secrets` list, which avoids requiring each developer to -attach the same project secrets manually. - -If any requested or attached value cannot be resolved, `workspace up` fails -rather than silently starting without it. +Devsy stores only the reference for an external secret. Repository-owned +`.devsy/config.yaml` can declare project-specific automatic +bindings with its `secrets` list. If any requested or attached value cannot be +resolved, `workspace up` fails rather than silently starting without it. ## SOPS Credential Discovery @@ -286,71 +272,15 @@ devsy workspace up . ``` For AWS KMS, GCP KMS, Azure Key Vault, PGP, and other key services supported by -SOPS, use the same ambient credentials and key configuration you would use with +SOPS, use the same credentials and key configuration you would use with SOPS itself. -Devsy does not automatically reuse the internal age identity used by its local -`file` storage backend. The Devsy storage key and a team's SOPS recipient key -belong to separate trust domains. - -## Git as the Source of Truth - -When a SOPS source is version-controlled, the encrypted file remains the -canonical persisted value. Updating and committing the encrypted file changes -what Devsy resolves the next time the workspace is started. There is no -`devsy secret set` import step to repeat after rotation. - -For remote Git workspaces, Devsy reads project configuration and encrypted -source documents from the same acquired revision so it does not combine secret -configuration from one commit with ciphertext from another. - ## How Secrets Are Protected -Regardless of source, sensitive values use the existing Devsy secret-delivery -path. - -- Lifecycle log output is redacted using the resolved secret values. -- Secret values are delivered through Devsy's protected agent transport rather - than embedded in lifecycle command lines. -- `type=mount` values use the in-memory `/run/secrets` mechanism and are not - baked into an image layer. -- SOPS plaintext is kept in command-scoped process memory; Devsy does not - intentionally persist the decrypted document or resolved values. -- SOPS age private keys, PGP private keys, and cloud KMS credentials remain - managed by SOPS and the user's environment rather than being imported into - the Devsy secret store. -- Missing sources, missing keys, invalid documents, and decryption failures fail - closed. - -Go-managed memory cannot provide a guarantee of immediate physical -zeroization. The guarantee is that Devsy does not intentionally create a -persistent plaintext copy of a resolved SOPS value. - -## SOPS Source vs. Devsy File Backend - -The two features both use encryption but serve different purposes. - -### Devsy `file` Backend - -- Devsy owns the secret values. -- Values are stored in Devsy's configuration directory. -- Devsy encrypts its local store with age. -- Configure it through `SECRETS_BACKEND=file`. - -### SOPS Source - -- The SOPS document owns the secret values. -- The encrypted document may live in Git. -- SOPS manages encryption metadata, recipients, and KMS integrations. -- Devsy decrypts requested values at use time. -- Values are not imported into Devsy's local store. - -Do not set `SECRETS_BACKEND=sops`; SOPS is a source, not a Devsy storage -backend. ## Choosing a Devsy Storage Backend -For Devsy-managed values, supported backends remain: +For Devsy-managed values, use the supported backends: - `keyring` — the OS keyring (Keychain / Credential Manager / libsecret). - `file` — an [age](https://age-encryption.org)-encrypted `secrets.enc` stored @@ -372,13 +302,6 @@ disk. ## Headless and CI Environments -Devsy-managed `file` secrets continue to work in headless environments without -an OS keyring. SOPS sources additionally require whatever age/PGP/KMS -credentials the encrypted document itself requires. - -Keep those credentials in the CI platform's protected secret mechanism and -expose them using the standard SOPS discovery mechanism. Do not commit private -SOPS identities alongside the encrypted file. ## Managed Environment Variables From 6db09a1cb4f77ccd472a507e7c7bd3e1dc030b1d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 7 Sep 2026 20:41:55 +0000 Subject: [PATCH 17/17] feat(secrets): support customizations.devsy in devcontainer.json - discover repository secret sources from customizations.devsy in effective devcontainer.json - remove legacy .devsy/config.yaml configuration - pin remote Git inspection to immutable commit SHA (99A.1) - enforce explicitly relative paths and reject absolute/traversal paths (99A.3) - serialize and atomically write local secret sources config (99A.4) - address review feedback on cancellation safety, git-token help, and target validation - update documentation and integration tests --- cmd/ci/ci.go | 2 +- cmd/workspace/up/project_secrets.go | 98 ++++++++-- cmd/workspace/up/project_secrets_test.go | 179 +++++++++++++++--- cmd/workspace/up/up_client.go | 160 ++++++++++------ cmd/workspace/up/up_client_test.go | 11 +- cmd/workspace/up/up_flags.go | 2 +- e2e/tests/up/sops.go | 36 ++++ .../docker-sops-project/.devcontainer.json | 14 +- .../docker-sops-project/.devsy/config.yaml | 6 - pkg/devcontainer/config/config.go | 20 +- pkg/devcontainer/config/result.go | 13 +- pkg/git/inspect.go | 172 ++++++++++++++++- pkg/git/inspect_test.go | 65 ++++++- pkg/secrets/project_config.go | 155 +++++++++++++-- pkg/secrets/project_config_test.go | 95 +++++++++- pkg/secrets/sops_source.go | 20 +- pkg/secrets/sops_source_test.go | 24 +++ pkg/secrets/source_config.go | 47 ++++- .../docs/developing-in-workspaces/secrets.mdx | 55 +++--- 19 files changed, 1005 insertions(+), 169 deletions(-) delete mode 100644 e2e/tests/up/testdata/docker-sops-project/.devsy/config.yaml diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go index 398cc342e..81b32dc87 100644 --- a/cmd/ci/ci.go +++ b/cmd/ci/ci.go @@ -153,7 +153,7 @@ func (cmd *CICmd) registerSecretsFlags(ciCmd *cobra.Command) { "(RUN --mount=type=secret,id=NAME). Repeatable"), cliflags.String(&cmd.GitTokenSecret, names.GitToken, "", "Secret holding an access token for cloning a private HTTP repository; "+ - "repository-owned sources cannot bootstrap their own clone"), + "accepts NAME or TYPE:SOURCE/NAME; repository-owned sources cannot bootstrap their own clone"), cliflags.String(&cmd.GitTokenUsername, names.GitTokenUsername, "", "Username for --git-token (default inferred from the repo host)"), ) diff --git a/cmd/workspace/up/project_secrets.go b/cmd/workspace/up/project_secrets.go index 3d5a40bd4..d0f128e25 100644 --- a/cmd/workspace/up/project_secrets.go +++ b/cmd/workspace/up/project_secrets.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "path/filepath" + "strconv" + "strings" gitpkg "github.com/devsy-org/devsy/pkg/git" provider2 "github.com/devsy-org/devsy/pkg/provider" @@ -48,7 +50,15 @@ func (cmd *UpCmd) discoverProjectSecrets( return nil, nil } if source.LocalFolder != "" { - return discoverLocalProjectSecrets(source.LocalFolder) + folder := source.LocalFolder + if source.GitSubPath != "" { + cleanSubPath, err := secrets.CleanProjectSourcePath(source.GitSubPath) + if err != nil { + return nil, fmt.Errorf("invalid subpath %q: %w", source.GitSubPath, err) + } + folder = filepath.Join(folder, filepath.FromSlash(cleanSubPath)) + } + return cmd.discoverLocalProjectSecrets(folder) } if source.GitRepository == "" { return nil, nil @@ -56,12 +66,16 @@ func (cmd *UpCmd) discoverProjectSecrets( return cmd.discoverRemoteProjectSecrets(ctx, source) } -func discoverLocalProjectSecrets(root string) (*projectSecretContext, error) { +func (cmd *UpCmd) discoverLocalProjectSecrets(root string) (*projectSecretContext, error) { root, err := filepath.Abs(root) if err != nil { return nil, err } - cfg, found, err := secrets.LoadProjectConfigFromRoot(root) + cfg, found, err := secrets.LoadProjectConfigFromRootWithOptions( + root, + cmd.DevContainerPath, + cmd.DevContainerID, + ) if err != nil || !found { return nil, err } @@ -96,19 +110,61 @@ func (cmd *UpCmd) discoverRemoteProjectSecrets( return nil, err } defer func() { _ = inspection.Close() }() + if inspection.Revision() != "" { + source.GitCommit = inspection.Revision() + } + + cfg, err := cmd.readRemoteProjectConfig(ctx, inspection) + if err != nil || cfg == nil { + return nil, err + } - configBytes, err := inspection.ReadFile(ctx, secrets.ProjectConfigPath) + sources, err := loadRemoteProjectSources(ctx, inspection, cfg) if err != nil { - if errors.Is(err, gitpkg.ErrRevisionPathNotFound) { - return nil, nil + return nil, err + } + return &projectSecretContext{config: cfg, sources: sources}, nil +} + +func (cmd *UpCmd) readRemoteProjectConfig( + ctx context.Context, + inspection *gitpkg.Inspection, +) (*secrets.ProjectConfig, error) { + devContainerBytes, _, err := inspection.ReadDevContainerConfig( + ctx, + cmd.DevContainerPath, + cmd.DevContainerID, + ) + if err == nil { + cfg, parseErr := parseValidProjectConfig(devContainerBytes) + if parseErr != nil { + return nil, fmt.Errorf("parse devcontainer.json: %w", parseErr) } - return nil, fmt.Errorf("discover repository secret configuration: %w", err) + return cfg, nil } - cfg, err := secrets.ParseProjectConfig(configBytes) + if !errors.Is(err, gitpkg.ErrRevisionPathNotFound) { + return nil, fmt.Errorf("discover repository devcontainer configuration: %w", err) + } + return nil, nil +} + +func parseValidProjectConfig(data []byte) (*secrets.ProjectConfig, error) { + parsed, err := secrets.ParseProjectConfig(data) if err != nil { return nil, err } - project := &projectSecretContext{config: cfg, sources: map[string]secrets.Source{}} + if parsed != nil && (len(parsed.SecretSources) > 0 || len(parsed.Secrets) > 0) { + return parsed, nil + } + return nil, nil +} + +func loadRemoteProjectSources( + ctx context.Context, + inspection *gitpkg.Inspection, + cfg *secrets.ProjectConfig, +) (map[string]secrets.Source, error) { + sources := make(map[string]secrets.Source, len(cfg.SecretSources)) for _, sourceConfig := range cfg.SecretSources { cleanPath, err := secrets.CleanProjectSourcePath(sourceConfig.Path) if err != nil { @@ -123,14 +179,14 @@ func (cmd *UpCmd) discoverRemoteProjectSecrets( err, ) } - project.sources[sourceConfig.Name] = secrets.NewSOPSDataSource( + sources[sourceConfig.Name] = secrets.NewSOPSDataSource( sourceConfig.Name, cleanPath, sourceConfig.Format, encrypted, ) } - return project, nil + return sources, nil } func (cmd *UpCmd) gitInspectionEnv() []string { @@ -148,10 +204,22 @@ func (cmd *UpCmd) gitInspectionEnv() []string { } credential := base64.StdEncoding.EncodeToString([]byte(username + ":" + cmd.GitToken.Token)) key := "http.https://" + host + "/.extraHeader" - return append(env, - "GIT_CONFIG_COUNT=1", - "GIT_CONFIG_KEY_0="+key, - "GIT_CONFIG_VALUE_0=Authorization: Basic "+credential, + count := 0 + outEnv := make([]string, 0, len(env)+3) + for _, entry := range env { + if val, ok := strings.CutPrefix(entry, "GIT_CONFIG_COUNT="); ok { + if n, err := strconv.Atoi(val); err == nil { + count = n + } + continue + } + outEnv = append(outEnv, entry) + } + + return append(outEnv, + fmt.Sprintf("GIT_CONFIG_COUNT=%d", count+1), + fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", count, key), + fmt.Sprintf("GIT_CONFIG_VALUE_%d=Authorization: Basic %s", count, credential), ) } diff --git a/cmd/workspace/up/project_secrets_test.go b/cmd/workspace/up/project_secrets_test.go index 49b42e345..7ef2bcb5f 100644 --- a/cmd/workspace/up/project_secrets_test.go +++ b/cmd/workspace/up/project_secrets_test.go @@ -3,7 +3,9 @@ package up import ( "context" "os" + "os/exec" "path/filepath" + "strings" "testing" "github.com/devsy-org/devsy/cmd/flags" @@ -44,27 +46,38 @@ func (f *fakeWorkspaceClient) Lock(context.Context) error { return nil func (f *fakeWorkspaceClient) Unlock() {} const ( - // Synthetic test-only identity generated solely for this fixture. gitleaks:allow. - testProjectSecretsAgeIdentity = "AGE-SECRET-KEY-12UWYSAH2MRDQ5K4EWC4253PDTCSCS32Y5EFQ8TEN2SL3QYU2GN2SG88CZX" - testProjectSecretsPlaintext = "SUPER_SECRET_TEST_VALUE_7B91" - testProjectEncryptedFixture = "testdata/sops-project-secrets.enc.yaml" + // Synthetic test-only identity generated solely for this fixture. + testProjectAgeIdentity = "AGE-SECRET-KEY-12UWYSAH2MRDQ5K4EWC4253PDTCSCS32Y5EFQ8TEN2SL3QYU2GN2SG88CZX" // gitleaks:allow + testProjectPlaintext = "SUPER_SECRET_TEST_VALUE_7B91" + testProjectFixture = "testdata/sops-project-secrets.enc.yaml" ) func newTestProjectWorkspace(t *testing.T) *fakeWorkspaceClient { t.Helper() root := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(root, ".devsy"), 0o750)) + devcontainerDir := filepath.Join(root, ".devcontainer") + require.NoError(t, os.MkdirAll(devcontainerDir, 0o750)) require.NoError(t, os.WriteFile( - filepath.Join(root, ".devsy", "config.yaml"), - []byte("secretSources:\n"+ - " - name: project\n"+ - " type: sops\n"+ - " path: secrets.enc.yaml\n"+ - "secrets:\n"+ - " - sops:project/SOPS_E2E_SECRET\n"), + filepath.Join(devcontainerDir, "devcontainer.json"), + []byte(`{ + "customizations": { + "devsy": { + "secretSources": [ + { + "name": "project", + "type": "sops", + "path": "secrets.enc.yaml" + } + ], + "secrets": [ + "sops:project/SOPS_E2E_SECRET" + ] + } + } +}`), 0o600, )) - fixture, err := os.ReadFile(testProjectEncryptedFixture) + fixture, err := os.ReadFile(testProjectFixture) require.NoError(t, err) require.NoError(t, os.WriteFile( // #nosec G703 -- t.TempDir()-derived path filepath.Join(root, "secrets.enc.yaml"), @@ -78,28 +91,39 @@ func newTestProjectWorkspace(t *testing.T) *fakeWorkspaceClient { } func TestPrepareResolvedWorkspaceSecrets_DiscoversLocalProjectSecrets(t *testing.T) { - t.Setenv("SOPS_AGE_KEY", testProjectSecretsAgeIdentity) + t.Setenv("SOPS_AGE_KEY", testProjectAgeIdentity) client := newTestProjectWorkspace(t) cmd := &UpCmd{} err := cmd.prepareResolvedWorkspaceSecrets(context.Background(), testConfig(), client) require.NoError(t, err) - require.Contains(t, cmd.SecretsEnv, "SOPS_E2E_SECRET="+testProjectSecretsPlaintext) + require.Contains(t, cmd.SecretsEnv, "SOPS_E2E_SECRET="+testProjectPlaintext) } func newFailingProjectWorkspace(t *testing.T) *fakeWorkspaceClient { t.Helper() root := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(root, ".devsy"), 0o750)) + devcontainerDir := filepath.Join(root, ".devcontainer") + require.NoError(t, os.MkdirAll(devcontainerDir, 0o750)) require.NoError(t, os.WriteFile( - filepath.Join(root, ".devsy", "config.yaml"), - []byte("secretSources:\n"+ - " - name: project\n"+ - " type: sops\n"+ - " path: nonexistent.enc.yaml\n"+ - "secrets:\n"+ - " - sops:project/MISSING\n"), + filepath.Join(devcontainerDir, "devcontainer.json"), + []byte(`{ + "customizations": { + "devsy": { + "secretSources": [ + { + "name": "project", + "type": "sops", + "path": "nonexistent.enc.yaml" + } + ], + "secrets": [ + "sops:project/MISSING" + ] + } + } +}`), 0o600, )) return &fakeWorkspaceClient{ @@ -157,3 +181,112 @@ func TestPrepareClient_NewWorkspaceCleanedUpOnError(t *testing.T) { "newly created workspace must be cleaned up on secret error", ) } + +func setupTestGitRepoWithDevContainer( + t *testing.T, + dir string, +) (func(args ...string) string, string) { + t.Helper() + runGit := func(args ...string) string { + c := exec.Command( + "git", + args...) // #nosec G204 -- test runner helper with controlled arguments + c.Dir = dir + c.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + out, err := c.CombinedOutput() + require.NoError(t, err, string(out)) + return strings.TrimSpace(string(out)) + } + + runGit("init", "-b", "main") + runGit("config", "user.name", "Test") + runGit("config", "user.email", "test@example.com") + + devcontainerDir := filepath.Join(dir, ".devcontainer") + require.NoError(t, os.MkdirAll(devcontainerDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(devcontainerDir, "devcontainer.json"), + []byte(`{ + "customizations": { + "devsy": { + "secretSources": [ + {"name": "project", "type": "sops", "path": "secrets.enc.yaml"} + ], + "secrets": ["sops:project/SOPS_E2E_SECRET"] + } + } +}`), + 0o600, + )) + fixture, err := os.ReadFile(testProjectFixture) + require.NoError(t, err) + require.NoError( + t, + os.WriteFile(filepath.Join(dir, "secrets.enc.yaml"), fixture, 0o600), + ) // #nosec G703 -- test temp dir + runGit("add", ".") + runGit("commit", "-m", "initial commit with secrets") + commitA := runGit("rev-parse", "HEAD") + return runGit, commitA +} + +func TestDiscoverRemoteProjectSecrets_PinsImmutableCommitSHA(t *testing.T) { + t.Setenv("SOPS_AGE_KEY", testProjectAgeIdentity) + gitDir := t.TempDir() + runGit, commitA := setupTestGitRepoWithDevContainer(t, gitDir) + + source := &provider.WorkspaceSource{ + GitRepository: gitDir, + GitBranch: "main", + } + + cmd := &UpCmd{} + projectCtx, err := cmd.discoverProjectSecrets(context.Background(), source) + require.NoError(t, err) + require.NotNil(t, projectCtx) + + // Verify immutable SHA pinning (99A.1) + require.Equal(t, commitA, source.GitCommit, "inspected source must be pinned to commit A") + + // Advance the branch to commit B + require.NoError( + t, + os.WriteFile(filepath.Join(gitDir, "dummy.txt"), []byte("advance branch"), 0o600), + ) + runGit("add", "dummy.txt") + runGit("commit", "-m", "advance branch to commit B") + commitB := runGit("rev-parse", "HEAD") + require.NotEqual(t, commitA, commitB) + + // Rediscover using the source that has GitCommit pinned to commitA + projectCtx2, err := cmd.discoverProjectSecrets(context.Background(), source) + require.NoError(t, err) + require.NotNil(t, projectCtx2) + require.Equal(t, commitA, source.GitCommit, "rediscovered source must remain pinned to commitA") +} + +func TestDiscoverProjectSecrets_RejectsAbsolutePaths(t *testing.T) { + root := t.TempDir() + devcontainerDir := filepath.Join(root, ".devcontainer") + require.NoError(t, os.MkdirAll(devcontainerDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(devcontainerDir, "devcontainer.json"), + []byte(`{ + "customizations": { + "devsy": { + "secretSources": [ + {"name": "project", "type": "sops", "path": "/secrets.enc.yaml"} + ], + "secrets": ["sops:project/SOPS_E2E_SECRET"] + } + } +}`), + 0o600, + )) + + source := &provider.WorkspaceSource{LocalFolder: root} + cmd := &UpCmd{} + _, err := cmd.discoverProjectSecrets(context.Background(), source) + require.Error(t, err) + require.Contains(t, err.Error(), "must be relative to the repository root") +} diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index b67872033..6da5f81c5 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -80,16 +80,7 @@ func (cmd *UpCmd) prepareClient( cmd.resolveSSHConfig(devsyConfig) args = cmd.ensureArgs(args) - var existed bool - switch { - case cmd.ID != "": - existed = workspace2.Exists(ctx, devsyConfig, nil, cmd.ID, cmd.Owner) != "" - case len(args) > 0: - _, name := file.IsLocalDir(args[0]) - existed = workspace2.Exists(ctx, devsyConfig, nil, workspace2.ToID(name), cmd.Owner) != "" - default: - existed = true - } + existed := cmd.checkWorkspaceExisted(ctx, devsyConfig, args) log.Debugf("resolving workspace with ide=%q launch=%q", cmd.IDE, cmd.IDELaunch) resolver := cmd.resolveWorkspace @@ -105,20 +96,47 @@ func (cmd *UpCmd) prepareClient( return nil, err } - if err := cmd.prepareResolvedWorkspaceSecrets(ctx, devsyConfig, client); err != nil { - if !existed { - _ = client.Delete(ctx, client2.DeleteOptions{Force: true, IgnoreNotFound: true}) - } + if err := cmd.postResolveWorkspace(ctx, devsyConfig, client, existed); err != nil { return nil, err } + return client, nil +} - if err := cmd.checkProviderUpdate(ctx, devsyConfig, client); err != nil { +func (cmd *UpCmd) checkWorkspaceExisted( + ctx context.Context, + devsyConfig *config.Config, + args []string, +) bool { + switch { + case cmd.ID != "": + return workspace2.Exists(ctx, devsyConfig, nil, cmd.ID, cmd.Owner) != "" + case len(args) > 0: + _, name := file.IsLocalDir(args[0]) + return workspace2.Exists(ctx, devsyConfig, nil, workspace2.ToID(name), cmd.Owner) != "" + default: + return true + } +} + +func (cmd *UpCmd) postResolveWorkspace( + ctx context.Context, + devsyConfig *config.Config, + client client2.BaseWorkspaceClient, + existed bool, +) error { + cleanupOnFailure := func(err error) error { if !existed { _ = client.Delete(ctx, client2.DeleteOptions{Force: true, IgnoreNotFound: true}) } - return nil, err + return err } - return client, nil + if err := cmd.prepareResolvedWorkspaceSecrets(ctx, devsyConfig, client); err != nil { + return cleanupOnFailure(err) + } + if err := cmd.checkProviderUpdate(ctx, devsyConfig, client); err != nil { + return cleanupOnFailure(err) + } + return nil } func (cmd *UpCmd) prepareClientEnvironment( @@ -332,41 +350,63 @@ func (cmd *UpCmd) applyLifecycleSecrets( // the setup argv and is process-list visible. func (cmd *UpCmd) applyEnvVars(ctx context.Context, resolver *secrets.Resolver) error { for _, entry := range cmd.EnvVars { - name, target, ok := strings.Cut(entry, "=") - if !ok { - target = name - } else if target == "" { - return fmt.Errorf("invalid --env %q: target after %q= must not be empty", entry, name) - } - ref, err := secrets.ParseRef(name) - if err != nil { - return err - } - if ref.Source != secrets.LocalSourceName { - return fmt.Errorf( - "--env only accepts Devsy-managed values; use --secret %s instead", - name, - ) - } - resolved, err := resolver.Resolve(ctx, ref) + envVar, err := resolveEnvVarEntry(ctx, resolver, entry) if err != nil { return err } - if resolved.Sensitive { - return fmt.Errorf( - "%q is a secret and cannot be passed with --env (it would be visible in the process list); use --secret %s instead", - name, - name, - ) - } - cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, target+"="+resolved.Value) + cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, envVar) } return nil } +func resolveEnvVarEntry( + ctx context.Context, + resolver *secrets.Resolver, + entry string, +) (string, error) { + name, explicitTarget, hasTarget := strings.Cut(entry, "=") + if hasTarget && explicitTarget == "" { + return "", fmt.Errorf("invalid --env %q: target after %q= must not be empty", entry, name) + } + ref, err := secrets.ParseRef(name) + if err != nil { + return "", err + } + target := ref.Name + if hasTarget { + target = explicitTarget + } + if ref.Source != secrets.LocalSourceName { + return "", fmt.Errorf( + "--env only accepts Devsy-managed values; use --secret %s instead", + name, + ) + } + resolved, err := resolver.Resolve(ctx, ref) + if err != nil { + return "", err + } + if resolved.Sensitive { + return "", fmt.Errorf( + "%q is a secret and cannot be passed with --env (it would be visible in the process list); use --secret %s instead", + name, + name, + ) + } + return target + "=" + resolved.Value, nil +} + func (cmd *UpCmd) applyBuildSecrets(ctx context.Context, resolver *secrets.Resolver) error { + if len(cmd.BuildSecretNames) == 0 { + return nil + } seen := map[string]string{} - built := make([]string, 0, len(cmd.BuildSecretNames)) + for _, existing := range cmd.BuildSecrets { + if k, _, ok := strings.Cut(existing, "="); ok { + seen[k] = existing + } + } + built := append([]string(nil), cmd.BuildSecrets...) for _, value := range cmd.BuildSecretNames { ref, err := secrets.ParseRef(value) if err != nil { @@ -466,7 +506,7 @@ func collectSecretRequests( } requests := sortedSecretRequests(byName) - if err := checkDuplicateMountTargets(requests); err != nil { + if err := checkDuplicateTargets(requests); err != nil { return nil, err } return requests, nil @@ -509,19 +549,29 @@ func sortedSecretRequests(byName map[string]secretRequest) []secretRequest { return requests } -func checkDuplicateMountTargets(requests []secretRequest) error { - targets := map[string]string{} +func checkDuplicateTargets(requests []secretRequest) error { + envTargets := map[string]string{} + mountTargets := map[string]string{} for _, req := range requests { - if !req.mount { - continue - } - if other, dup := targets[req.target]; dup { - return fmt.Errorf( - "secrets %q and %q both mount to target %q; give one a distinct target=", - other, req.ref.String(), req.target, - ) + if req.mount { + if other, dup := mountTargets[req.target]; dup { + return fmt.Errorf( + "secrets %q and %q both mount to target %q; give one a distinct target=", + other, req.ref.String(), req.target, + ) + } + mountTargets[req.target] = req.ref.String() + } else { + if other, dup := envTargets[req.target]; dup { + return fmt.Errorf( + "secrets %q and %q both inject to environment variable %q; give one a distinct target=", + other, + req.ref.String(), + req.target, + ) + } + envTargets[req.target] = req.ref.String() } - targets[req.target] = req.ref.String() } return nil } diff --git a/cmd/workspace/up/up_client_test.go b/cmd/workspace/up/up_client_test.go index 160065e16..940c4a586 100644 --- a/cmd/workspace/up/up_client_test.go +++ b/cmd/workspace/up/up_client_test.go @@ -2,10 +2,11 @@ package up import "testing" +const testSnapshotImage = "ghcr.io/acme/snapshots:my-ws-20260731150405-abcxyz" + func TestEnsureArgsForFromSnapshot_SynthesizesPlaceholderWhenArgsEmpty(t *testing.T) { cmd := &UpCmd{} - cmd.FromSnapshot = "ghcr.io/acme/snapshots:my-ws-20260731150405-abcxyz" - + cmd.FromSnapshot = testSnapshotImage args := cmd.ensureArgsForFromSnapshot(nil) if len(args) != 1 || args[0] != cmd.FromSnapshot { @@ -19,8 +20,7 @@ func TestEnsureArgsForFromSnapshot_SynthesizesPlaceholderWhenArgsEmpty(t *testin func TestEnsureArgsForFromSnapshot_LeavesNonEmptyArgsUntouched(t *testing.T) { cmd := &UpCmd{} - cmd.FromSnapshot = "ghcr.io/acme/snapshots:my-ws-20260731150405-abcxyz" - + cmd.FromSnapshot = testSnapshotImage args := cmd.ensureArgsForFromSnapshot([]string{"already-set"}) if len(args) != 1 || args[0] != "already-set" { @@ -54,9 +54,8 @@ func TestEnsureArgs_SynthesizesPlaceholderWhenSourceProvidedAndArgsEmpty(t *test func TestEnsureArgs_PrefersFromSnapshotOverSourceWhenBothEmptyArgs(t *testing.T) { cmd := &UpCmd{} - cmd.FromSnapshot = "ghcr.io/acme/snapshots:my-ws-20260731150405-abcxyz" + cmd.FromSnapshot = testSnapshotImage cmd.Source = "https://github.com/devsy-org/devsy" - args := cmd.ensureArgs(nil) if len(args) != 1 || args[0] != cmd.FromSnapshot { diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index e944228a1..23ea87dc5 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -238,7 +238,7 @@ func (cmd *UpCmd) registerWorkspaceSecretsFlags(upCmd *cobra.Command) { "(RUN --mount=type=secret,id=NAME). Repeatable"), flags.String(&cmd.GitTokenSecret, names.GitToken, "", "Secret holding an access token for cloning a private HTTP repository; "+ - "repository-owned sources cannot bootstrap their own clone"), + "accepts NAME or TYPE:SOURCE/NAME; repository-owned sources cannot bootstrap their own clone"), flags.String(&cmd.GitTokenUsername, names.GitTokenUsername, "", "Username for --git-token (default inferred from the repo host)"), flags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "", diff --git a/e2e/tests/up/sops.go b/e2e/tests/up/sops.go index e4869485c..2d802d162 100644 --- a/e2e/tests/up/sops.go +++ b/e2e/tests/up/sops.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -93,6 +94,41 @@ var _ = ginkgo.Describe( }, ginkgo.SpecTimeout(framework.TimeoutShort()), ) + ginkgo.It( + "discovers remote Git repository-owned SOPS sources from customizations.devsy", + func(ctx context.Context) { + useSOPSAgeIdentity() + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-sops-project", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + // Initialize a real Git repository in tempDir + execGit := func(args ...string) { + c := exec.CommandContext(ctx, "git", args...) // #nosec G204 -- test helper + c.Dir = tempDir + out, gitErr := c.CombinedOutput() + framework.ExpectNoError(gitErr, string(out)) + } + execGit("init", "-b", "main") + execGit("config", "user.name", "Devsy") + execGit("config", "user.email", "devsy@example.com") + execGit("add", ".") + execGit("commit", "-m", "initial commit") + + // Start workspace pointing to the Git repository + gitURI := "file://" + filepath.ToSlash(tempDir) + err = dtc.f.DevsyUp(ctx, gitURI) + framework.ExpectNoError(err) + + value, err := dtc.execSSH(ctx, tempDir, "cat /tmp/sops-project-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(value)).To(gomega.Equal(sopsE2EPlaintext)) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) ginkgo.It( "fails closed when SOPS credentials are unavailable without leaking plaintext", diff --git a/e2e/tests/up/testdata/docker-sops-project/.devcontainer.json b/e2e/tests/up/testdata/docker-sops-project/.devcontainer.json index 89b5b5169..aa1b2d3a1 100644 --- a/e2e/tests/up/testdata/docker-sops-project/.devcontainer.json +++ b/e2e/tests/up/testdata/docker-sops-project/.devcontainer.json @@ -1,4 +1,16 @@ { "image": "ghcr.io/devsy-org/test-images/go:1", - "postCreateCommand": "printf '%s' \"$SOPS_E2E_SECRET\" > /tmp/sops-project-check.out" + "postCreateCommand": "printf '%s' \"$SOPS_E2E_SECRET\" > /tmp/sops-project-check.out", + "customizations": { + "devsy": { + "secretSources": [ + { + "name": "project", + "type": "sops", + "path": "secrets.enc.yaml" + } + ], + "secrets": ["sops:project/SOPS_E2E_SECRET"] + } + } } diff --git a/e2e/tests/up/testdata/docker-sops-project/.devsy/config.yaml b/e2e/tests/up/testdata/docker-sops-project/.devsy/config.yaml deleted file mode 100644 index 5251ffe8a..000000000 --- a/e2e/tests/up/testdata/docker-sops-project/.devsy/config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -secretSources: - - name: project - type: sops - path: secrets.enc.yaml -secrets: - - sops:project/SOPS_E2E_SECRET diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index 6b8c23b2e..f325d7895 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/devsy-org/devsy/pkg/types" ) @@ -561,8 +562,23 @@ func matchPortRegex(key, portStr string) bool { } type DevsyCustomizations struct { - PrebuildRepository types.StrArray `json:"prebuildRepository,omitempty"` - FeatureDownloadHTTPHeaders map[string]string `json:"featureDownloadHTTPHeaders,omitempty"` + PrebuildRepository types.StrArray `json:"prebuildRepository,omitempty"` + FeatureDownloadHTTPHeaders map[string]string `json:"featureDownloadHTTPHeaders,omitempty"` + SecretSources []secrets.SourceConfig `json:"secretSources,omitempty"` + Secrets []string `json:"secrets,omitempty"` +} + +func (d *DevsyCustomizations) ToProjectConfig() *secrets.ProjectConfig { + if d == nil { + return nil + } + if len(d.SecretSources) == 0 && len(d.Secrets) == 0 { + return nil + } + return &secrets.ProjectConfig{ + SecretSources: append([]secrets.SourceConfig(nil), d.SecretSources...), + Secrets: append([]string(nil), d.Secrets...), + } } type VSCodeCustomizations struct { diff --git a/pkg/devcontainer/config/result.go b/pkg/devcontainer/config/result.go index d1000aec6..9fdddf33b 100644 --- a/pkg/devcontainer/config/result.go +++ b/pkg/devcontainer/config/result.go @@ -121,13 +121,20 @@ func userFromContainerLabel(result *Result) string { } func GetDevsyCustomizations(parsedConfig *DevContainerConfig) *DevsyCustomizations { - if parsedConfig.Customizations == nil || - parsedConfig.Customizations[pkgconfig.BinaryName] == nil { + if parsedConfig == nil || parsedConfig.Customizations == nil { + return &DevsyCustomizations{} + } + + customization := parsedConfig.Customizations["devsy"] + if customization == nil { + customization = parsedConfig.Customizations[pkgconfig.BinaryName] + } + if customization == nil { return &DevsyCustomizations{} } devsy := &DevsyCustomizations{} - err := convert(parsedConfig.Customizations[pkgconfig.BinaryName], devsy) + err := convert(customization, devsy) if err != nil { return &DevsyCustomizations{} } diff --git a/pkg/git/inspect.go b/pkg/git/inspect.go index 466c61c5b..a965e97e3 100644 --- a/pkg/git/inspect.go +++ b/pkg/git/inspect.go @@ -49,12 +49,29 @@ func InspectRemote(ctx context.Context, info *GitInfo, env []string) (*Inspectio _ = os.RemoveAll(root) return nil, err } + commitSHA, err := resolveCommitSHA(ctx, repo, rev) + if err != nil { + _ = os.RemoveAll(root) + return nil, err + } subPath, err := cleanInspectionSubPath(info.SubPath) if err != nil { _ = os.RemoveAll(root) return nil, err } - return &Inspection{repo: repo, rev: rev, root: root, subPath: subPath}, nil + return &Inspection{repo: repo, rev: commitSHA, root: root, subPath: subPath}, nil +} + +func resolveCommitSHA(ctx context.Context, repo *Repo, rev string) (string, error) { + revResult, err := repo.run(ctx, "rev-parse", rev+"^{commit}") + if err != nil { + return "", fmt.Errorf("resolve immutable commit sha: %w", err) + } + commitSHA := strings.TrimSpace(string(revResult.Stdout)) + if commitSHA == "" { + return "", fmt.Errorf("empty commit sha for revision %q", rev) + } + return commitSHA, nil } func cloneInspectionRepo( @@ -99,8 +116,12 @@ func fetchInspectionPR( if number == "" { return "", fmt.Errorf("invalid pull/merge request reference %q", request) } + candidates := prCandidates(repository) + if len(candidates) == 0 { + return "", fmt.Errorf("unsupported repository host for pull/merge request %q", repository) + } var lastErr error - for _, host := range prCandidates(repository) { + for _, host := range candidates { refspec := host.Refspec(number) _, err := repo.run(ctx, "fetch", "--depth=1", "origin", refspec) if err == nil { @@ -169,11 +190,14 @@ func (i *Inspection) ReadFile(ctx context.Context, filePath string) ([]byte, err // absolute or that would escape the repository root once joined with // another repository-relative path. func cleanRepoRelativePath(kind, value string) (string, error) { - value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) - value = strings.TrimPrefix(value, "/") + value = strings.TrimSpace(value) if value == "" { return "", nil } + if err := validateRepoRelativePath(kind, value); err != nil { + return "", err + } + value = strings.TrimPrefix(strings.ReplaceAll(value, `\`, "/"), "/") clean := path.Clean(value) if clean == "." { return "", nil @@ -184,6 +208,52 @@ func cleanRepoRelativePath(kind, value string) (string, error) { return clean, nil } +func validateRepoRelativePath(kind, value string) error { + if filepath.VolumeName(value) != "" || isWindowsAbs(value) { + return fmt.Errorf("git %s %q must be relative to the repository root", kind, value) + } + if kind == "file path" && (strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`)) { + return fmt.Errorf("git %s %q must be relative to the repository root", kind, value) + } + return nil +} + +func isWindowsAbs(value string) bool { + if len(value) >= 2 && value[1] == ':' { + c := value[0] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + return true + } + } + return strings.HasPrefix(value, `\\`) || strings.HasPrefix(value, `//`) +} + +// ReadDevContainerConfig locates and reads the devcontainer.json configuration +// from the inspection repository at its pinned revision, searching explicit paths, +// standard root locations (.devcontainer/devcontainer.json, .devcontainer.json), +// and profile-specific paths (.devcontainer//devcontainer.json). +func (i *Inspection) ReadDevContainerConfig( + ctx context.Context, + devContainerPath, devContainerID string, +) ([]byte, string, error) { + if i == nil || i.repo == nil { + return nil, "", fmt.Errorf("git inspection is closed") + } + if devContainerPath != "" { + return i.readExplicitDevContainerConfig(ctx, devContainerPath) + } + if devContainerID != "" { + return i.readProfileDevContainerConfig(ctx, devContainerID) + } + if data, pathFound, err := i.readRootDevContainerConfig( + ctx, + ); err == nil || + !errors.Is(err, ErrRevisionPathNotFound) { + return data, pathFound, err + } + return i.readNestedDevContainerConfig(ctx) +} + // cleanInspectionSubPath normalizes and validates a repository-relative // subpath selector (from an @subpath: reference), rejecting anything that // would escape the repository root once joined with a file path. @@ -207,3 +277,97 @@ func (i *Inspection) Close() error { i.repo = nil return os.RemoveAll(root) } + +func (i *Inspection) readExplicitDevContainerConfig( + ctx context.Context, + devContainerPath string, +) ([]byte, string, error) { + clean, err := cleanRepoRelativePath("devcontainer path", devContainerPath) + if err != nil { + return nil, "", err + } + data, err := i.ReadFile(ctx, clean) + if err != nil { + return nil, "", err + } + return data, clean, nil +} + +func (i *Inspection) readProfileDevContainerConfig( + ctx context.Context, + devContainerID string, +) ([]byte, string, error) { + cleanID, err := cleanRepoRelativePath("devcontainer id", devContainerID) + if err != nil { + return nil, "", err + } + idPath := path.Join(".devcontainer", cleanID, "devcontainer.json") + data, err := i.ReadFile(ctx, idPath) + if err == nil { + return data, idPath, nil + } + if !errors.Is(err, ErrRevisionPathNotFound) { + return nil, "", err + } + return nil, "", fmt.Errorf("devcontainer with ID %q not found in repository", devContainerID) +} + +func (i *Inspection) readRootDevContainerConfig(ctx context.Context) ([]byte, string, error) { + for _, candidate := range []string{ + path.Join(".devcontainer", "devcontainer.json"), + ".devcontainer.json", + } { + data, err := i.ReadFile(ctx, candidate) + if err == nil { + return data, candidate, nil + } + if !errors.Is(err, ErrRevisionPathNotFound) { + return nil, "", err + } + } + return nil, "", ErrRevisionPathNotFound +} + +func (i *Inspection) readNestedDevContainerConfig(ctx context.Context) ([]byte, string, error) { + nested := i.listDevContainerConfigs(ctx) + if len(nested) == 1 { + data, err := i.ReadFile(ctx, nested[0]) + if err != nil { + return nil, "", err + } + return data, nested[0], nil + } + if len(nested) > 1 { + return nil, "", fmt.Errorf("multiple devcontainer configurations found: %v", nested) + } + return nil, "", ErrRevisionPathNotFound +} + +func (i *Inspection) listDevContainerConfigs(ctx context.Context) []string { + treePath := ".devcontainer" + if i.subPath != "" { + treePath = path.Join(i.subPath, treePath) + } + targetTree := i.rev + ":" + treePath + result, err := i.repo.run(ctx, "ls-tree", "--name-only", targetTree) + if err != nil { + return nil + } + entries := strings.Split(strings.TrimSpace(string(result.Stdout)), "\n") + var matches []string + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + cand := path.Join(".devcontainer", entry, "devcontainer.json") + fullCand := cand + if i.subPath != "" { + fullCand = path.Join(i.subPath, cand) + } + if _, err := i.repo.run(ctx, "cat-file", "-e", i.rev+":"+fullCand); err == nil { + matches = append(matches, cand) + } + } + return matches +} diff --git a/pkg/git/inspect_test.go b/pkg/git/inspect_test.go index 0d2e43011..9f6bb20af 100644 --- a/pkg/git/inspect_test.go +++ b/pkg/git/inspect_test.go @@ -14,11 +14,11 @@ func TestInspectionReadFileUsesSubPath(t *testing.T) { repo := At("/tmp/repo", WithRunner(runner)) inspection := &Inspection{repo: repo, rev: inspectionHeadRev, subPath: testSubPath} - out, err := inspection.ReadFile(context.Background(), ".devsy/config.yaml") + out, err := inspection.ReadFile(context.Background(), ".devcontainer/devcontainer.json") assert.NilError(t, err) assert.Equal(t, string(out), "secret-contents") - wantObject := inspectionHeadRev + ":" + testSubPath + "/.devsy/config.yaml" + wantObject := inspectionHeadRev + ":" + testSubPath + "/.devcontainer/devcontainer.json" // cat-file existence check, then show; both must target the subpath. assert.Equal(t, len(runner.calls), 2) assert.Equal(t, runner.calls[0].Args[len(runner.calls[0].Args)-1], wantObject) @@ -30,9 +30,9 @@ func TestInspectionReadFileWithoutSubPathUsesRepoRoot(t *testing.T) { repo := At("/tmp/repo", WithRunner(runner)) inspection := &Inspection{repo: repo, rev: inspectionHeadRev} - _, err := inspection.ReadFile(context.Background(), ".devsy/config.yaml") + _, err := inspection.ReadFile(context.Background(), ".devcontainer/devcontainer.json") assert.NilError(t, err) - wantObject := inspectionHeadRev + ":.devsy/config.yaml" + wantObject := inspectionHeadRev + ":.devcontainer/devcontainer.json" assert.Equal(t, runner.calls[0].Args[len(runner.calls[0].Args)-1], wantObject) } @@ -72,3 +72,60 @@ func TestInspectionReadFileRejectsPathEscape(t *testing.T) { assert.Assert(t, err != nil) assert.Equal(t, len(runner.calls), 0) } + +func TestInspectionReadFileRejectsAbsolutePath(t *testing.T) { + runner := &fakeRunner{stdout: []byte("secret-contents")} + repo := At("/tmp/repo", WithRunner(runner)) + inspection := &Inspection{repo: repo, rev: inspectionHeadRev, subPath: testSubPath} + + for _, bad := range []string{ + "/secrets.enc.yaml", + "/etc/passwd", + `C:\secrets.enc.yaml`, + `\\server\share\secrets.enc.yaml`, + } { + _, err := inspection.ReadFile(context.Background(), bad) + assert.Assert(t, err != nil, bad) + } +} + +func TestInspectionReadDevContainerConfig_RootConfig(t *testing.T) { + runner := &fakeRunner{stdout: []byte(`{"customizations":{"devsy":{}}}`)} + repo := At("/tmp/repo", WithRunner(runner)) + inspection := &Inspection{repo: repo, rev: inspectionHeadRev} + + data, pathFound, err := inspection.ReadDevContainerConfig(context.Background(), "", "") + assert.NilError(t, err) + assert.Equal(t, pathFound, ".devcontainer/devcontainer.json") + assert.Assert(t, len(data) > 0) +} + +func TestInspectionReadDevContainerConfig_ExplicitPath(t *testing.T) { + runner := &fakeRunner{stdout: []byte(`{"customizations":{"devsy":{}}}`)} + repo := At("/tmp/repo", WithRunner(runner)) + inspection := &Inspection{repo: repo, rev: inspectionHeadRev} + + data, pathFound, err := inspection.ReadDevContainerConfig( + context.Background(), + "custom/devcontainer.json", + "", + ) + assert.NilError(t, err) + assert.Equal(t, pathFound, "custom/devcontainer.json") + assert.Assert(t, len(data) > 0) +} + +func TestInspectionReadDevContainerConfig_DevContainerID(t *testing.T) { + runner := &fakeRunner{stdout: []byte(`{"customizations":{"devsy":{}}}`)} + repo := At("/tmp/repo", WithRunner(runner)) + inspection := &Inspection{repo: repo, rev: inspectionHeadRev} + + data, pathFound, err := inspection.ReadDevContainerConfig( + context.Background(), + "", + "my-profile", + ) + assert.NilError(t, err) + assert.Equal(t, pathFound, ".devcontainer/my-profile/devcontainer.json") + assert.Assert(t, len(data) > 0) +} diff --git a/pkg/secrets/project_config.go b/pkg/secrets/project_config.go index b44fbc8ab..2543f3c45 100644 --- a/pkg/secrets/project_config.go +++ b/pkg/secrets/project_config.go @@ -1,6 +1,7 @@ package secrets import ( + "encoding/json" "errors" "fmt" "os" @@ -8,11 +9,11 @@ import ( "path/filepath" "strings" + "github.com/devsy-org/devsy/pkg/config" + "github.com/tailscale/hujson" "sigs.k8s.io/yaml" ) -const ProjectConfigPath = ".devsy/config.yaml" - // ProjectConfig is the repository-owned subset of Devsy configuration used by // secret discovery. type ProjectConfig struct { @@ -20,10 +21,17 @@ type ProjectConfig struct { Secrets []string `json:"secrets,omitempty" yaml:"secrets,omitempty"` } +type devContainerCustomizationsWrapper struct { + Customizations map[string]json.RawMessage `json:"customizations"` +} + func ParseProjectConfig(data []byte) (*ProjectConfig, error) { + if cfg, handled, err := parseDevContainerCustomizations(data); handled { + return cfg, err + } cfg := &ProjectConfig{} if err := yaml.Unmarshal(data, cfg); err != nil { - return nil, fmt.Errorf("parse %s: %w", ProjectConfigPath, err) + return nil, fmt.Errorf("parse project secret configuration: %w", err) } if err := ValidateProjectConfig(cfg); err != nil { return nil, err @@ -31,6 +39,32 @@ func ParseProjectConfig(data []byte) (*ProjectConfig, error) { return cfg, nil } +func parseDevContainerCustomizations(data []byte) (*ProjectConfig, bool, error) { + normalized, err := hujson.Standardize(data) + if err != nil { + return nil, false, nil + } + var wrapper devContainerCustomizationsWrapper + if err := json.Unmarshal(normalized, &wrapper); err != nil || wrapper.Customizations == nil { + return nil, false, nil + } + rawDevsy := wrapper.Customizations["devsy"] + if len(rawDevsy) == 0 { + rawDevsy = wrapper.Customizations[config.BinaryName] + } + if len(rawDevsy) == 0 { + return nil, true, nil + } + cfg := &ProjectConfig{} + if err := json.Unmarshal(rawDevsy, cfg); err != nil { + return nil, true, fmt.Errorf("parse customizations.devsy: %w", err) + } + if err := ValidateProjectConfig(cfg); err != nil { + return nil, true, err + } + return cfg, true, nil +} + func ValidateProjectConfig(cfg *ProjectConfig) error { if cfg == nil { return nil @@ -98,35 +132,130 @@ func validateProjectSecret(value string, sources map[string]struct{}) error { // CleanProjectSourcePath validates a repository-controlled source path and // returns a normalized repository-relative slash path. func CleanProjectSourcePath(value string) (string, error) { - value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) - value = strings.TrimPrefix(value, "/") + value = strings.TrimSpace(value) if value == "" { return "", fmt.Errorf("source path must not be empty") } - clean := path.Clean(value) + if err := validateRelativePath(value); err != nil { + return "", err + } + normalized := strings.ReplaceAll(value, `\`, "/") + clean := path.Clean(normalized) if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { return "", fmt.Errorf("source path %q escapes the repository root", value) } return strings.TrimPrefix(clean, "./"), nil } +func validateRelativePath(value string) error { + if filepath.VolumeName(value) != "" || isWindowsAbs(value) { + return fmt.Errorf("source path %q must be relative to the repository root", value) + } + if strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) { + return fmt.Errorf("source path %q must be relative to the repository root", value) + } + return nil +} + +func isWindowsAbs(value string) bool { + if len(value) >= 2 && value[1] == ':' { + c := value[0] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + return true + } + } + return strings.HasPrefix(value, `\\`) || strings.HasPrefix(value, `//`) +} + // LoadProjectConfigFromRoot loads repository-owned config from a local checkout. -// A missing .devsy/config.yaml is not an error. +// A missing configuration is not an error. func LoadProjectConfigFromRoot(root string) (*ProjectConfig, bool, error) { - configPath := filepath.Join(root, filepath.FromSlash(ProjectConfigPath)) - // #nosec G304 -- configPath is intentionally rooted under the selected repository. - data, err := os.ReadFile(configPath) + return LoadProjectConfigFromRootWithOptions(root, "", "") +} + +// LoadProjectConfigFromRootWithOptions loads repository-owned config from a local checkout, +// checking the specified devcontainer path, conventional root devcontainer locations, +// and profile-specific devcontainer directories for customizations.devsy. +func LoadProjectConfigFromRootWithOptions( + root, devContainerPath, devContainerID string, +) (*ProjectConfig, bool, error) { + candidates, err := devContainerCandidates(devContainerPath, devContainerID, root) + if err != nil { + return nil, false, err + } + for _, relPath := range candidates { + if cfg, found, err := loadCandidateConfig(root, relPath); found || err != nil { + return cfg, found, err + } + } + return nil, false, nil +} + +func devContainerCandidates(devContainerPath, devContainerID, root string) ([]string, error) { + if devContainerPath != "" { + clean, err := CleanProjectSourcePath(devContainerPath) + if err != nil { + return nil, err + } + return []string{clean}, nil + } + candidates := []string{ + path.Join(".devcontainer", "devcontainer.json"), + ".devcontainer.json", + } + if devContainerID != "" { + cleanID, err := CleanProjectSourcePath(devContainerID) + if err != nil { + return nil, fmt.Errorf("invalid devcontainer id %q: %w", devContainerID, err) + } + return []string{path.Join(".devcontainer", cleanID, "devcontainer.json")}, nil + } + if nested := findNestedDevContainer(root); nested != "" { + candidates = append(candidates, nested) + } + return candidates, nil +} + +func findNestedDevContainer(root string) string { + devcontainerDir := filepath.Join(root, ".devcontainer") + entries, err := os.ReadDir(devcontainerDir) + if err != nil { + return "" + } + var nested []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + cand := filepath.Join(devcontainerDir, entry.Name(), "devcontainer.json") + if _, err := os.Stat(cand); err == nil { + nested = append(nested, path.Join(".devcontainer", entry.Name(), "devcontainer.json")) + } + } + if len(nested) == 1 { + return nested[0] + } + return "" +} + +func loadCandidateConfig(root, relPath string) (*ProjectConfig, bool, error) { + fullPath := filepath.Join(root, filepath.FromSlash(relPath)) + // #nosec G304 -- fullPath is rooted under the selected repository. + data, err := os.ReadFile(fullPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, false, nil } - return nil, false, fmt.Errorf("read %s: %w", ProjectConfigPath, err) + return nil, false, fmt.Errorf("read %s: %w", relPath, err) } cfg, err := ParseProjectConfig(data) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf("parse %s: %w", relPath, err) } - return cfg, true, nil + if cfg != nil && (len(cfg.SecretSources) > 0 || len(cfg.Secrets) > 0) { + return cfg, true, nil + } + return nil, false, nil } // ResolveProjectSourcePath converts a repository-controlled relative path to a diff --git a/pkg/secrets/project_config_test.go b/pkg/secrets/project_config_test.go index 47c312e0f..e22636b5b 100644 --- a/pkg/secrets/project_config_test.go +++ b/pkg/secrets/project_config_test.go @@ -24,6 +24,47 @@ secrets: require.Equal(t, []string{"sops:project/API_TOKEN"}, cfg.Secrets) } +func TestParseProjectConfig_DevContainerCustomizations(t *testing.T) { + cfg, err := ParseProjectConfig([]byte(`{ + // DevContainer with devsy customizations + "name": "my-project", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "devsy": { + "secretSources": [ + { + "name": "project", + "type": "sops", + "path": "./secrets.enc.yaml", + }, + ], + "secrets": [ + "sops:project/API_KEY", + ], + }, + }, +}`)) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Len(t, cfg.SecretSources, 1) + require.Equal(t, "project", cfg.SecretSources[0].Name) + require.Equal(t, []string{"sops:project/API_KEY"}, cfg.Secrets) +} + +func TestParseProjectConfig_DevContainerWithoutCustomizations(t *testing.T) { + cfg, err := ParseProjectConfig([]byte(`{ + "name": "my-project", + "image": "ubuntu", + "customizations": { + "vscode": { + "extensions": ["golang.go"] + } + } +}`)) + require.NoError(t, err) + require.Nil(t, cfg) +} + func TestProjectConfigRejectsUndefinedSource(t *testing.T) { _, err := ParseProjectConfig([]byte(`secrets: [sops:missing/API_TOKEN]`)) require.ErrorContains(t, err, "undefined source") @@ -34,14 +75,58 @@ func TestCleanProjectSourcePath(t *testing.T) { require.NoError(t, err) require.Equal(t, "config/secrets.enc.yaml", clean) - cleanAbs, err := CleanProjectSourcePath("/config/secrets.enc.yaml") - require.NoError(t, err) - require.Equal(t, "config/secrets.enc.yaml", cleanAbs) - - for _, bad := range []string{"", "../secret", "a/../../secret"} { + for _, bad := range []string{ + "", + "/config/secrets.enc.yaml", + "/secrets.enc.yaml", + "/etc/passwd", + `C:\secrets.enc.yaml`, + `\\server\share\secrets.enc.yaml`, + "../secret", + "a/../../secret", + "..", + ".", + } { _, err := CleanProjectSourcePath(bad) require.Error(t, err, bad) } + for _, good := range []string{ + "./secrets.enc.yaml", + "config/secrets.enc.yaml", + "secrets.enc.yaml", + } { + clean, err := CleanProjectSourcePath(good) + require.NoError(t, err, good) + require.False(t, filepath.IsAbs(clean)) + } +} + +func TestLoadProjectConfigFromRoot_DevContainerCustomizations(t *testing.T) { + root := t.TempDir() + devcontainerDir := filepath.Join(root, ".devcontainer") + require.NoError(t, os.MkdirAll(devcontainerDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(devcontainerDir, "devcontainer.json"), + []byte(`{ + "customizations": { + "devsy": { + "secretSources": [ + {"name": "project", "type": "sops", "path": "secrets.enc.yaml"} + ], + "secrets": ["sops:project/KEY"] + } + } +}`), + 0o600, + )) + + cfg, found, err := LoadProjectConfigFromRoot(root) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, cfg) + require.Len(t, cfg.SecretSources, 1) + require.Equal(t, "project", cfg.SecretSources[0].Name) + require.Equal(t, []string{"sops:project/KEY"}, cfg.Secrets) } func TestResolveProjectSourcePathRejectsSymlinkEscape(t *testing.T) { diff --git a/pkg/secrets/sops_source.go b/pkg/secrets/sops_source.go index 4f362f194..fdec2a11b 100644 --- a/pkg/secrets/sops_source.go +++ b/pkg/secrets/sops_source.go @@ -33,9 +33,10 @@ type SOPSSource struct { format string encrypted []byte - once sync.Once - data map[string]string - err error + once sync.Once + data map[string]string + err error + loadHook func() } func NewSOPSSource(name, filePath, format string) *SOPSSource { @@ -60,7 +61,10 @@ func (s *SOPSSource) Get(ctx context.Context, name string) (ResolvedSecret, erro if s == nil { return ResolvedSecret{}, fmt.Errorf("SOPS secret source is nil") } - s.once.Do(func() { s.data, s.err = s.load(ctx) }) + s.once.Do(func() { s.data, s.err = s.load(context.Background()) }) + if err := ctx.Err(); err != nil { + return ResolvedSecret{}, err + } if s.err != nil { return ResolvedSecret{}, s.err } @@ -80,11 +84,17 @@ func (s *SOPSSource) Validate(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } - s.once.Do(func() { s.data, s.err = s.load(ctx) }) + s.once.Do(func() { s.data, s.err = s.load(context.Background()) }) + if err := ctx.Err(); err != nil { + return err + } return s.err } func (s *SOPSSource) load(ctx context.Context) (map[string]string, error) { + if s.loadHook != nil { + s.loadHook() + } if err := ctx.Err(); err != nil { return nil, err } diff --git a/pkg/secrets/sops_source_test.go b/pkg/secrets/sops_source_test.go index c30931432..e8eca87ed 100644 --- a/pkg/secrets/sops_source_test.go +++ b/pkg/secrets/sops_source_test.go @@ -136,3 +136,27 @@ func TestNormalizeSOPSFormat(t *testing.T) { require.Equal(t, tc.want, got) } } + +func TestSOPSSource_CanceledFirstCallerDoesNotPoisonLaterCaller(t *testing.T) { + t.Setenv("SOPS_AGE_KEY", testSOPSAgeIdentity) + source := NewSOPSDataSource( + "test-source", + "secrets.enc.yaml", + SOPSFormatYAML, + encryptedSOPSTestFixture(t), + ) + + callerCtx, cancel := context.WithCancel(context.Background()) + // Hook cancels caller context after load execution has started inside once.Do, + // exercising the once.Do completion path rather than the pre-call guard. + source.loadHook = func() { + cancel() + } + + _, err := source.Get(callerCtx, "SOPS_E2E_SECRET") + require.ErrorIs(t, err, context.Canceled) + + resolved, err := source.Get(context.Background(), "SOPS_E2E_SECRET") + require.NoError(t, err) + require.Equal(t, testSOPSPlaintextMarker, resolved.Value) +} diff --git a/pkg/secrets/source_config.go b/pkg/secrets/source_config.go index dc030f944..792ec3c34 100644 --- a/pkg/secrets/source_config.go +++ b/pkg/secrets/source_config.go @@ -128,11 +128,52 @@ func writeSourceRegistry(filePath string, registry sourceRegistryFile) error { if err != nil { return fmt.Errorf("marshal secret source config: %w", err) } - if err := os.MkdirAll(filepath.Dir(filePath), 0o700); err != nil { + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0o700); err != nil { return err } - if err := os.WriteFile(filePath, data, 0o600); err != nil { - return fmt.Errorf("write secret source config: %w", err) + tmpName, err := createTempSourceRegistry(dir, data) + if err != nil { + return err + } + return replaceSourceRegistry(tmpName, filePath, dir) +} + +func createTempSourceRegistry(dir string, data []byte) (string, error) { + tmpFile, err := os.CreateTemp(dir, sourceConfigFileName+".tmp-*") + if err != nil { + return "", fmt.Errorf("create temp secret source config: %w", err) + } + tmpName := tmpFile.Name() + var success bool + defer func() { + _ = tmpFile.Close() + if !success { + _ = os.Remove(tmpName) + } + }() + if err := tmpFile.Chmod(0o600); err != nil { + return "", fmt.Errorf("chmod temp secret source config: %w", err) + } + if _, err := tmpFile.Write(data); err != nil { + return "", fmt.Errorf("write temp secret source config: %w", err) + } + if err := tmpFile.Sync(); err != nil { + return "", fmt.Errorf("sync temp secret source config: %w", err) + } + success = true + return tmpName, nil +} + +func replaceSourceRegistry(tmpName, filePath, dir string) error { + defer func() { _ = os.Remove(tmpName) }() + if err := os.Rename(tmpName, filePath); err != nil { + return fmt.Errorf("replace secret source config: %w", err) + } + // #nosec G304 -- dir is filepath.Dir(filePath) under Devsy's config directory. + if d, err := os.Open(dir); err == nil { + _ = d.Sync() + _ = d.Close() } return nil } diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 220e2f8cb..8b6b83e00 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -103,28 +103,37 @@ it. ### Repository-Owned SOPS Sources -A repository can declare SOPS sources in `.devsy/config.yaml`. +A repository can declare SOPS sources in either the root-level `.devcontainer.json` or `.devcontainer/devcontainer.json` layout under `customizations.devsy.secretSources`. ```text project/ ├── .devcontainer/ -├── .devsy/ -│ └── config.yaml +│ └── devcontainer.json └── secrets.enc.yaml ``` -Example `.devsy/config.yaml`: - -```yaml -secretSources: - - name: project - type: sops - path: ./secrets.enc.yaml - -secrets: - - sops:project/DATABASE_PASSWORD +Example `devcontainer.json`: + +```json +{ + "name": "My Project", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "devsy": { + "secretSources": [ + { + "name": "project", + "type": "sops", + "path": "./secrets.enc.yaml" + } + ], + "secrets": [ + "sops:project/DATABASE_PASSWORD" + ] + } + } +} ``` - Repository source paths are resolved relative to the repository root. Devsy rejects paths, including symlink targets, that escape the repository root. @@ -141,8 +150,8 @@ devsy workspace up https://github.com/acme/project ``` For a remote source Devsy first acquires repository data to read the requested Git -revision, then reads `.devsy/config.yaml` and the referenced -encrypted SOPS files from that same revision. +revision, then reads `customizations.devsy` from the effective Dev Container +configuration and the referenced encrypted SOPS files from that same revision. ### Bootstrap Authentication for Private Repositories @@ -253,7 +262,7 @@ devsy secret detach sops:project/API_TOKEN ``` Devsy stores only the reference for an external secret. Repository-owned -`.devsy/config.yaml` can declare project-specific automatic +`customizations.devsy` can declare project-specific automatic bindings with its `secrets` list. If any requested or attached value cannot be resolved, `workspace up` fails rather than silently starting without it. @@ -277,8 +286,14 @@ SOPS itself. ## How Secrets Are Protected +Devsy workspaces protect sensitive values by: -## Choosing a Devsy Storage Backend +- Decrypting SOPS sources in memory at workspace startup without persisting plaintext to disk +- Injecting secrets directly as environment variables or memory-backed files under `/run/secrets` +- Redacting known secret values from logs and terminal outputs +- Excluding secret values from client-server transport and workspace metadata + +### Choosing a Devsy Storage Backend For Devsy-managed values, use the supported backends: @@ -299,10 +314,6 @@ The local `file` backend generates and manages an encryption key automatically. For stronger at-rest protection, set a passphrase with `DEVSY_SECRETS_PASSPHRASE`; the derived encryption key is then not stored on disk. - -## Headless and CI Environments - - ## Managed Environment Variables For non-sensitive configuration, `devsy env` stores managed environment