diff --git a/pkg/client/compose/project.go b/pkg/client/compose/project.go index 3d29d653..e042b8e0 100644 --- a/pkg/client/compose/project.go +++ b/pkg/client/compose/project.go @@ -23,6 +23,7 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project registerComposeOverrides.Do(func() { transform.RegisterDefaultValue("services.*.deploy.update_config", setUpdateConfigDefaults) transform.RegisterDefaultValue("services.*.volumes.*.source", checkRelativeVolumeMount) + transform.RegisterDefaultValue("services.*.environment", setSecrets) }) defaultOpts := []composecli.ProjectOptionsFn{ diff --git a/pkg/client/compose/secrets.go b/pkg/client/compose/secrets.go new file mode 100644 index 00000000..e48159a7 --- /dev/null +++ b/pkg/client/compose/secrets.go @@ -0,0 +1,29 @@ +package compose + +import ( + "context" + + "github.com/compose-spec/compose-go/v2/tree" + "github.com/psviderski/uncloud/pkg/client/secrets" +) + +func setSecrets(data any, _ tree.Path, _ bool) (any, error) { + switch v := data.(type) { + case map[string]any: + // environment + for name, pattern := range v { + if x, ok := pattern.(string); ok { + resolver, pattern, err := secrets.Parse(x) + if err != nil { + continue + } + secret, err := resolver.Secrets(context.TODO(), pattern) + if err != nil { + return data, err + } + v[name] = secret.Value + } + } + } + return data, nil +} diff --git a/pkg/client/secrets/fnox.go b/pkg/client/secrets/fnox.go new file mode 100644 index 00000000..9dba8401 --- /dev/null +++ b/pkg/client/secrets/fnox.go @@ -0,0 +1,39 @@ +package secrets + +import ( + "context" + "os/exec" + "strings" +) + +// Fnox implements the use of the fnox secret manager. It calls fnox directly so that should be set up. +// +// The pattern uc://fnox/profile/foo, will end up there as profile/foo. Where profile is optional, so +// foo is valid as well. +type Fnox struct{} + +const fnox = "fnox" + +func (f *Fnox) Secrets(ctx context.Context, pattern string) (Secret, error) { + fields := strings.Split(pattern, "/") + var args []string + switch len(fields) { + case 1: + args = []string{"get", fields[0]} + case 2: + args = []string{"-P", fields[0], "get", fields[1]} + default: + return Secret{}, ErrNotFound + } + + cmd := exec.CommandContext(ctx, fnox, args...) + out, err := cmd.Output() + if err != nil { + return Secret{}, ErrAccessDenied + } + return Secret{ + ID: pattern, + Value: out, + Provider: fnox, + }, nil +} diff --git a/pkg/client/secrets/secrets.go b/pkg/client/secrets/secrets.go new file mode 100644 index 00000000..fcccfa84 --- /dev/null +++ b/pkg/client/secrets/secrets.go @@ -0,0 +1,54 @@ +// Package secrets defines the interface to 3rd party secret managers. It resolves secrets to their actual +// data. This only reads secrets, settings and updating them is left out. +package secrets + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" +) + +const Scheme = "uc" + +// Secret is a secret as resolved by a providing plugin. +type Secret struct { + ID string + Value []byte + Provider string +} + +var ( + ErrNotFound = errors.New("secret not found") + ErrAccessDenied = errors.New("access denied") + ErrNoProvider = errors.New("provider not found") +) + +// providers is the list of plugins we have to resolve a secret. +var providers = map[string]Resolver{ + fnox: &Fnox{}, +} + +// Resolver is the interface all providers should implement. +type Resolver interface { + Secrets(ctx context.Context, pattern string) (Secret, error) +} + +// Parse parses a pattern uc:///bla/foo and returns the provider +// and the secrets pattern that is used. The provider implementation knows how to deal with the pattern. +func Parse(pointer string) (resolver Resolver, pattern string, err error) { + u, err := url.Parse(pointer) + if err != nil { + return nil, "", err + } + if u.Scheme != Scheme { + return nil, "", fmt.Errorf("unknown scheme: %s", u.Scheme) + } + r, ok := providers[u.Hostname()] + if !ok { + return nil, "", fmt.Errorf("%s %w", u.Hostname(), ErrNoProvider) + } + + return r, strings.TrimPrefix(u.Path, "/"), nil +}