diff --git a/go.mod b/go.mod index 01889fd320..32299896a7 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 3425e9ab2a..3d21e6d583 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 9a0699da7c..cdf7628cac 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 0000000000..c750be0322 --- /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 0000000000..9166849517 --- /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 0000000000..3da4eae643 --- /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 +}