Files
treatvault/internal/provider/docker.go

313 lines
9.4 KiB
Go

// 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_<name> 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_<name> 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")
}