313 lines
9.4 KiB
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")
|
|
}
|