From ae994fb029a02ee06312aecd461639c4cdfaa584 Mon Sep 17 00:00:00 2001 From: Johan Nilsson Date: Sat, 6 Jun 2026 22:57:00 +0200 Subject: [PATCH] Resolve ref+ secret references in env values Add support for resolving secret references in service environment values using the ref+openbao://path#/key URI scheme, compatible with the convention established by helmfile/vals and ArgoCD Vault Plugin. Supported backend: - ref+openbao:// (OpenBao KV v2) The implementation uses a pluggable resolver interface (SecretResolver), making it straightforward to add new backends without modifying existing code. Secrets are resolved at project load time, after interpolation but before container creation. Authentication is handled via standard environment variables (BAO_ADDR, BAO_TOKEN, BAO_CACERT, BAO_SKIP_VERIFY). Closes #13821 Signed-off-by: Johan Nilsson --- go.mod | 10 ++ go.sum | 26 ++++ pkg/compose/loader.go | 5 + pkg/compose/resolver_openbao.go | 82 +++++++++++ pkg/compose/valsresolver.go | 131 +++++++++++++++++ pkg/compose/valsresolver_test.go | 234 +++++++++++++++++++++++++++++++ 6 files changed, 488 insertions(+) create mode 100644 pkg/compose/resolver_openbao.go create mode 100644 pkg/compose/valsresolver.go create mode 100644 pkg/compose/valsresolver_test.go diff --git a/go.mod b/go.mod index 01889fd320e..32299896a73 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/moby/patternmatcher v0.6.1 github.com/moby/sys/atomicwriter v0.1.0 github.com/morikuni/aec v1.1.0 + github.com/openbao/openbao/api/v2 v2.5.1 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/otiai10/copy v1.14.1 @@ -62,6 +63,7 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/containerd/api v1.10.0 // indirect @@ -77,6 +79,7 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fvbommel/sortorder v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gofrs/flock v0.13.0 // indirect @@ -89,6 +92,11 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect 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-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/in-toto/attestation v1.1.2 // indirect github.com/in-toto/in-toto-golang v0.10.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -100,6 +108,7 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/sys/capability v0.4.0 // indirect @@ -115,6 +124,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // 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/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect diff --git a/go.sum b/go.sum index 3425e9ab2a5..3d21e6d5835 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -110,12 +112,16 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o/c= github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -163,6 +169,8 @@ github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= @@ -194,10 +202,22 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +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/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= @@ -236,6 +256,8 @@ github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/buildkit v0.29.0 h1:wxLEFbCOJntEDjSNNN2YWd8zxltZxT5muDQ0LzpbtpU= github.com/moby/buildkit v0.29.0/go.mod h1:Dmv2FeDe34t75QuzeU87rBoZpAAkcpT5zeu4hXzmASc= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -277,6 +299,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/openbao/openbao/api/v2 v2.5.1 h1:Br79D6L20SbAa5P7xqENxmvv8LyI4HoKosPy7klhn4o= +github.com/openbao/openbao/api/v2 v2.5.1/go.mod h1:Dh5un77tqGgMbmlVEqjqN+8/dMyUohnkaQVg/wXW0Ig= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -314,6 +338,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= diff --git a/pkg/compose/loader.go b/pkg/compose/loader.go index 9a0699da7c6..cdf7628cacc 100644 --- a/pkg/compose/loader.go +++ b/pkg/compose/loader.go @@ -64,6 +64,11 @@ func (s *composeService) LoadProject(ctx context.Context, options api.ProjectLoa return nil, err } + // Resolve ref+ secret references (OpenBao, Vault, etc.) + if err := resolveSecretReferences(project); err != nil { + return nil, err + } + return project, nil } diff --git a/pkg/compose/resolver_openbao.go b/pkg/compose/resolver_openbao.go new file mode 100644 index 00000000000..c750be03225 --- /dev/null +++ b/pkg/compose/resolver_openbao.go @@ -0,0 +1,82 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "fmt" + "strings" + + openbao "github.com/openbao/openbao/api/v2" +) + +// openbaoResolver resolves secrets from OpenBao using KV v2. +// Authentication is handled via environment variables: +// - BAO_ADDR — server address +// - BAO_TOKEN — authentication token +// - BAO_CACERT — CA certificate path +// - BAO_SKIP_VERIFY — skip TLS verification +type openbaoResolver struct { + client *openbao.Client +} + +func newOpenbaoResolver() (SecretResolver, error) { + client, err := openbao.NewClient(openbao.DefaultConfig()) + if err != nil { + return nil, fmt.Errorf("creating openbao client: %w", err) + } + return &openbaoResolver{client: client}, nil +} + +// Resolve reads a secret from OpenBao KV v2 at the given path and +// returns the value of the specified key. +func (r *openbaoResolver) Resolve(path, key string) (string, error) { + kvPath := insertKVv2Data(path) + + secret, err := r.client.Logical().Read(kvPath) + if err != nil { + return "", fmt.Errorf("reading %q: %w", path, err) + } + if secret == nil || secret.Data == nil { + return "", fmt.Errorf("no data at path %q", path) + } + + // KV v2 wraps actual data under a "data" key + data, ok := secret.Data["data"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected data format at path %q", path) + } + + val, ok := data[key] + if !ok { + return "", fmt.Errorf("key %q not found at path %q", key, path) + } + return fmt.Sprintf("%v", val), nil +} + +// insertKVv2Data transforms "mount/path/to/secret" into "mount/data/path/to/secret" +// for KV v2 API compatibility. If the second segment is already "data", the path +// is returned unchanged to avoid double insertion. +func insertKVv2Data(path string) string { + parts := strings.SplitN(path, "/", 3) + if len(parts) < 2 { + return path + } + if parts[1] == "data" { + return path + } + return parts[0] + "/data/" + strings.Join(parts[1:], "/") +} diff --git a/pkg/compose/valsresolver.go b/pkg/compose/valsresolver.go new file mode 100644 index 00000000000..91668495171 --- /dev/null +++ b/pkg/compose/valsresolver.go @@ -0,0 +1,131 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "fmt" + "strings" + + "github.com/compose-spec/compose-go/v2/types" +) + +const refPrefix = "ref+" + +// SecretResolver resolves a secret reference to its actual value. +// Implementations handle backend-specific logic (API calls, auth, etc.). +type SecretResolver interface { + Resolve(path, key string) (string, error) +} + +// resolverFactory creates a resolver instance. Called lazily on first use. +type resolverFactory func() (SecretResolver, error) + +// resolverRegistry maps URI scheme prefixes to their factory functions. +// To add a new backend, register it here and implement SecretResolver. +var resolverRegistry = map[string]resolverFactory{ + "ref+openbao://": newOpenbaoResolver, +} + +// resolveSecretReferences resolves environment values prefixed with "ref+" +// by dispatching to the appropriate backend resolver based on URI scheme. +func resolveSecretReferences(project *types.Project) error { + if !projectHasRefs(project) { + return nil + } + + // Cache resolver instances so we only create one per backend + resolvers := map[string]SecretResolver{} + + for name, svc := range project.Services { + for k, v := range svc.Environment { + if v == nil || !strings.HasPrefix(*v, refPrefix) { + continue + } + + resolver, err := getResolver(*v, resolvers) + if err != nil { + return fmt.Errorf("resolving %q for service %q: %w", k, name, err) + } + + path, key, err := parseRef(*v) + if err != nil { + return fmt.Errorf("resolving %q for service %q: %w", k, name, err) + } + + resolved, err := resolver.Resolve(path, key) + if err != nil { + return fmt.Errorf("resolving %q for service %q: %w", k, name, err) + } + svc.Environment[k] = &resolved + } + project.Services[name] = svc + } + return nil +} + +// getResolver returns the cached resolver for the given ref URI, creating it +// on first use via the registry factory. +func getResolver(ref string, cache map[string]SecretResolver) (SecretResolver, error) { + for prefix, factory := range resolverRegistry { + if strings.HasPrefix(ref, prefix) { + if r, ok := cache[prefix]; ok { + return r, nil + } + r, err := factory() + if err != nil { + return nil, err + } + cache[prefix] = r + return r, nil + } + } + return nil, fmt.Errorf("unsupported secret reference scheme in %q", ref) +} + +// parseRef extracts the path and key from a ref+ URI. +// Format: ref+://path/to/secret#/key +func parseRef(ref string) (string, string, error) { + // Strip the "ref+://" prefix + for prefix := range resolverRegistry { + if strings.HasPrefix(ref, prefix) { + ref = strings.TrimPrefix(ref, prefix) + break + } + } + + parts := strings.SplitN(ref, "#", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid ref %q: missing #/key", ref) + } + + path := parts[0] + key := strings.TrimPrefix(parts[1], "/") + return path, key, nil +} + +// projectHasRefs returns true if any service environment value starts with "ref+", +// allowing early exit when no resolution is needed. +func projectHasRefs(project *types.Project) bool { + for _, svc := range project.Services { + for _, v := range svc.Environment { + if v != nil && strings.HasPrefix(*v, refPrefix) { + return true + } + } + } + return false +} diff --git a/pkg/compose/valsresolver_test.go b/pkg/compose/valsresolver_test.go new file mode 100644 index 00000000000..3da4eae6435 --- /dev/null +++ b/pkg/compose/valsresolver_test.go @@ -0,0 +1,234 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "fmt" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "gotest.tools/v3/assert" +) + +func TestProjectHasRefs(t *testing.T) { + str := func(s string) *string { return &s } + + tests := []struct { + name string + project *types.Project + expected bool + }{ + { + name: "no refs", + project: &types.Project{ + Services: types.Services{ + "web": {Environment: types.MappingWithEquals{"FOO": str("bar")}}, + }, + }, + expected: false, + }, + { + name: "has openbao ref", + project: &types.Project{ + Services: types.Services{ + "web": {Environment: types.MappingWithEquals{ + "SECRET": str("ref+openbao://secret/data/prod/db#/password"), + }}, + }, + }, + expected: true, + }, + { + name: "nil value ignored", + project: &types.Project{ + Services: types.Services{ + "web": {Environment: types.MappingWithEquals{"FOO": nil}}, + }, + }, + expected: false, + }, + { + name: "empty project", + project: &types.Project{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, projectHasRefs(tt.project), tt.expected) + }) + } +} + +func TestResolveSecretReferences_NoRefs(t *testing.T) { + str := func(s string) *string { return &s } + + project := &types.Project{ + Services: types.Services{ + "web": {Environment: types.MappingWithEquals{"FOO": str("bar")}}, + }, + } + + err := resolveSecretReferences(project) + assert.NilError(t, err) + assert.Equal(t, *project.Services["web"].Environment["FOO"], "bar") +} + +func TestResolveSecretReferences_UnsupportedScheme(t *testing.T) { + str := func(s string) *string { return &s } + + project := &types.Project{ + Services: types.Services{ + "web": {Environment: types.MappingWithEquals{ + "SECRET": str("ref+unsupported://some/path#/key"), + }}, + }, + } + + err := resolveSecretReferences(project) + assert.ErrorContains(t, err, "unsupported secret reference scheme") +} + +func TestResolveSecretReferences_WithMockResolver(t *testing.T) { + str := func(s string) *string { return &s } + + // Register a mock resolver for testing + original := resolverRegistry + resolverRegistry = map[string]resolverFactory{ + "ref+mock://": func() (SecretResolver, error) { + return &mockResolver{secrets: map[string]map[string]string{ + "secret/prod/db": { + "username": "admin", + "password": "s3cret", + }, + }}, nil + }, + } + defer func() { resolverRegistry = original }() + + project := &types.Project{ + Services: types.Services{ + "web": {Environment: types.MappingWithEquals{ + "DB_USER": str("ref+mock://secret/prod/db#/username"), + "DB_PASS": str("ref+mock://secret/prod/db#/password"), + "STATIC": str("plain-value"), + }}, + }, + } + + err := resolveSecretReferences(project) + assert.NilError(t, err) + assert.Equal(t, *project.Services["web"].Environment["DB_USER"], "admin") + assert.Equal(t, *project.Services["web"].Environment["DB_PASS"], "s3cret") + assert.Equal(t, *project.Services["web"].Environment["STATIC"], "plain-value") +} + +func TestResolveSecretReferences_ResolverError(t *testing.T) { + str := func(s string) *string { return &s } + + original := resolverRegistry + resolverRegistry = map[string]resolverFactory{ + "ref+mock://": func() (SecretResolver, error) { + return &mockResolver{secrets: map[string]map[string]string{}}, nil + }, + } + defer func() { resolverRegistry = original }() + + project := &types.Project{ + Services: types.Services{ + "web": {Environment: types.MappingWithEquals{ + "SECRET": str("ref+mock://secret/missing/path#/key"), + }}, + }, + } + + err := resolveSecretReferences(project) + assert.ErrorContains(t, err, "no data at path") +} + +func TestParseRef(t *testing.T) { + tests := []struct { + ref string + wantPath string + wantKey string + }{ + { + ref: "ref+openbao://secret/g/team/app/prod/db#/password", + wantPath: "secret/g/team/app/prod/db", + wantKey: "password", + }, + { + ref: "ref+openbao://secret/prod/postgres#/username", + wantPath: "secret/prod/postgres", + wantKey: "username", + }, + } + + for _, tt := range tests { + t.Run(tt.ref, func(t *testing.T) { + path, key, err := parseRef(tt.ref) + assert.NilError(t, err) + assert.Equal(t, path, tt.wantPath) + assert.Equal(t, key, tt.wantKey) + }) + } +} + +func TestParseRef_Invalid(t *testing.T) { + _, _, err := parseRef("ref+openbao://secret/path/without/key") + assert.ErrorContains(t, err, "missing #/key") +} + +func TestInsertKVv2Data(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"secret/myapp/db", "secret/data/myapp/db"}, + {"secret/g/team/app/prod/db", "secret/data/g/team/app/prod/db"}, + {"kv/foo", "kv/data/foo"}, + {"kv/prod/api", "kv/data/prod/api"}, + {"secret/data/myapp/db", "secret/data/myapp/db"}, + {"kv/data/prod/api", "kv/data/prod/api"}, + {"secret/myapp/data/db", "secret/data/myapp/data/db"}, + {"single", "single"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, insertKVv2Data(tt.input), tt.expected) + }) + } +} + +// mockResolver implements SecretResolver for testing. +type mockResolver struct { + secrets map[string]map[string]string +} + +func (m *mockResolver) Resolve(path, key string) (string, error) { + data, ok := m.secrets[path] + if !ok { + return "", fmt.Errorf("no data at path %q", path) + } + val, ok := data[key] + if !ok { + return "", fmt.Errorf("key %q not found at path %q", key, path) + } + return val, nil +}