// Package provider reconciles TreatVault snapshots with platform secret providers. package provider import ( "context" "encoding/json" "errors" "fmt" "os/exec" "sort" "strings" barkfile "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" "cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault" ) var ErrSecretInUse = errors.New("secret is in use") type Runner interface { Run(context.Context, []byte, ...string) (string, error) } type ExecRunner struct { Path string } func (r ExecRunner) Run(ctx context.Context, input []byte, args ...string) (string, error) { path := r.Path if path == "" { path = "docker" } command := exec.CommandContext(ctx, path, args...) if input != nil { command.Stdin = strings.NewReader(string(input)) } output, err := command.CombinedOutput() if err != nil { return string(output), fmt.Errorf("docker %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) } return string(output), nil } type SecretState struct { Name string Revision string PhysicalName string InUse bool } type Docker struct { Runner Runner } // Sync reconciles snapshot with Docker Swarm: it creates one immutable secret // object per record revision, rotates mounts on labeled consumer services, and // removes obsolete objects. Names listed in a service's // io.barkstack.treatvault.names label are mounted at barkstack_ targets. func (d Docker) Sync(ctx context.Context, snapshot vault.Snapshot) ([]SecretState, error) { if d.Runner == nil { return nil, errors.New("Docker runner is required") } managed, err := d.managedSecrets(ctx, snapshot.VaultID) if err != nil { return nil, err } for name, record := range snapshot.Secrets { physical := physicalName(snapshot.VaultID, record.Revision) if containsSecret(managed, physical) { continue } args := []string{ "secret", "create", "--label", barkfile.TreatVaultManagedLabel + "=true", "--label", barkfile.TreatVaultSecretNameLabel + "=" + name, "--label", barkfile.TreatVaultVaultIDLabel + "=" + snapshot.VaultID, "--label", barkfile.TreatVaultRevisionLabel + "=" + record.Revision, physical, "-", } if _, err := d.Runner.Run(ctx, record.Value, args...); err != nil { return nil, fmt.Errorf("create Docker secret for %q: %w", name, err) } } services, err := d.managedServices(ctx) if err != nil { return nil, err } inUse := make(map[string]bool) missing := make(map[string]struct{}) desiredTargets := make(map[string]struct{}, len(snapshot.Secrets)) for name := range snapshot.Secrets { desiredTargets[barkfile.DockerSecretName(name)] = struct{}{} } for _, service := range services { args := []string{"service", "update"} changed := false mountedByTarget := make(map[string]serviceSecret, len(service.Secrets)) for _, mounted := range service.Secrets { mountedByTarget[mounted.Target] = mounted if _, managedTarget := desiredTargets[mounted.Target]; managedTarget { inUse[strings.TrimPrefix(mounted.Target, barkfile.DockerSecretPrefix)] = true } } for _, name := range service.DesiredNames { record, stored := snapshot.Secrets[name] if !stored { missing[name] = struct{}{} continue } inUse[name] = true desiredPhysical := physicalName(snapshot.VaultID, record.Revision) target := barkfile.DockerSecretName(name) mounted, isMounted := mountedByTarget[target] if isMounted && mounted.Source == desiredPhysical { continue } if isMounted { args = append(args, "--secret-rm", mounted.Source) } args = append(args, "--secret-add", secretMount(desiredPhysical, target)) changed = true } for _, mounted := range service.Secrets { logical, managedTarget := strings.CutPrefix(mounted.Target, barkfile.DockerSecretPrefix) if !managedTarget { continue } if _, desired := desiredTargets[mounted.Target]; desired { continue } // Mounted under a Barkstack target but absent from the vault: // keep the mount so consumers keep working, surface the gap. missing[logical] = struct{}{} } if changed { args = append(args, service.Name) if _, err := d.Runner.Run(ctx, nil, args...); err != nil { return nil, fmt.Errorf("rotate secrets on service %q: %w", service.Name, err) } } } desiredPhysical := make(map[string]struct{}, len(snapshot.Secrets)) for _, record := range snapshot.Secrets { desiredPhysical[physicalName(snapshot.VaultID, record.Revision)] = struct{}{} } for _, secret := range managed { if _, desired := desiredPhysical[secret.Name]; desired { continue } if _, err := d.Runner.Run(ctx, nil, "secret", "rm", secret.Name); err != nil && !isInUseError(err) { return nil, fmt.Errorf("remove obsolete Docker secret %q: %w", secret.Name, err) } } states := make([]SecretState, 0, len(snapshot.Secrets)) for name, record := range snapshot.Secrets { states = append(states, SecretState{ Name: name, Revision: record.Revision, PhysicalName: physicalName(snapshot.VaultID, record.Revision), InUse: inUse[name], }) } sort.Slice(states, func(i, j int) bool { return states[i].Name < states[j].Name }) if len(missing) != 0 { names := make([]string, 0, len(missing)) for name := range missing { names = append(names, name) } sort.Strings(names) return states, fmt.Errorf("%w: %s", ErrSecretInUse, strings.Join(names, ", ")) } return states, nil } // InUse reports whether any managed service currently mounts the secret at its // barkstack_ target. func (d Docker) InUse(ctx context.Context, name string) (bool, error) { services, err := d.managedServices(ctx) if err != nil { return false, err } target := barkfile.DockerSecretName(name) for _, service := range services { for _, secret := range service.Secrets { if secret.Target == target { return true, nil } } } return false, nil } type dockerSecret struct { Name string LogicalName string Revision string VaultID string } func containsSecret(secrets []dockerSecret, name string) bool { for _, secret := range secrets { if secret.Name == name { return true } } return false } func (d Docker) managedSecrets(ctx context.Context, vaultID string) ([]dockerSecret, error) { output, err := d.Runner.Run(ctx, nil, "secret", "ls", "--quiet", "--filter", "label="+barkfile.TreatVaultManagedLabel+"=true", "--filter", "label="+barkfile.TreatVaultVaultIDLabel+"="+vaultID, ) if err != nil { return nil, fmt.Errorf("list managed Docker secrets: %w", err) } ids := strings.Fields(output) secrets := make([]dockerSecret, 0, len(ids)) for _, id := range ids { output, err := d.Runner.Run(ctx, nil, "secret", "inspect", "--format", "{{json .Spec}}", id) if err != nil { return nil, fmt.Errorf("inspect Docker secret %q: %w", id, err) } var spec struct { Name string `json:"Name"` Labels map[string]string `json:"Labels"` } if err := json.Unmarshal([]byte(output), &spec); err != nil { return nil, fmt.Errorf("decode Docker secret %q: %w", id, err) } if spec.Labels[barkfile.TreatVaultManagedLabel] != "true" || spec.Labels[barkfile.TreatVaultVaultIDLabel] != vaultID { continue } secrets = append(secrets, dockerSecret{ Name: spec.Name, LogicalName: spec.Labels[barkfile.TreatVaultSecretNameLabel], Revision: spec.Labels[barkfile.TreatVaultRevisionLabel], VaultID: vaultID, }) } return secrets, nil } type serviceSecret struct { Source string Target string } type dockerService struct { Name string DesiredNames []string Secrets []serviceSecret } func (d Docker) managedServices(ctx context.Context) ([]dockerService, error) { output, err := d.Runner.Run(ctx, nil, "service", "ls", "--quiet", "--filter", "label="+barkfile.TreatVaultConsumerLabel+"=true", ) if err != nil { return nil, fmt.Errorf("list TreatVault consumer services: %w", err) } ids := strings.Fields(output) services := make([]dockerService, 0, len(ids)) for _, id := range ids { output, err := d.Runner.Run(ctx, nil, "service", "inspect", "--format", "{{json .Spec}}", id) if err != nil { return nil, fmt.Errorf("inspect Docker service %q: %w", id, err) } var spec struct { Name string `json:"Name"` Labels map[string]string `json:"Labels"` TaskTemplate struct { ContainerSpec struct { Secrets []struct { SecretName string `json:"SecretName"` File *struct { Name string `json:"Name"` } `json:"File"` } `json:"Secrets"` } `json:"ContainerSpec"` } `json:"TaskTemplate"` } if err := json.Unmarshal([]byte(output), &spec); err != nil { return nil, fmt.Errorf("decode Docker service %q: %w", id, err) } names := spec.Labels[barkfile.TreatVaultNamesLabel] var desiredNames []string if names != "" { desiredNames = strings.Split(names, ",") } service := dockerService{Name: spec.Name, DesiredNames: desiredNames} for _, secret := range spec.TaskTemplate.ContainerSpec.Secrets { if secret.File != nil { service.Secrets = append(service.Secrets, serviceSecret{Source: secret.SecretName, Target: secret.File.Name}) } } services = append(services, service) } return services, nil } func physicalName(vaultID, revision string) string { return barkfile.DockerSecretPrefix + "tv_" + vaultID + "_" + revision } func secretMount(source, target string) string { return "source=" + source + ",target=" + target } func isInUseError(err error) bool { message := strings.ToLower(err.Error()) return strings.Contains(message, "in use") || strings.Contains(message, "used by") }