feat: TreatVault encrypted secret manager with Docker Swarm sync and console plugin

This commit is contained in:
2026-09-16 16:06:41 -04:00
parent 61a45a4310
commit 63ee4a99e6
25 changed files with 5976 additions and 1 deletions

312
internal/provider/docker.go Normal file
View File

@@ -0,0 +1,312 @@
// 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")
}

View File

@@ -0,0 +1,184 @@
package provider
import (
"context"
"errors"
"strings"
"testing"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
)
const (
testVaultID = "0011223344556677"
testRevision = "00112233445566778899aabbccddeeff"
)
func serviceSpecJSON(names string, secrets string) string {
return `{"Name":"barkstack-pawsql","Labels":{"io.barkstack.treatvault.secrets":"true","io.barkstack.treatvault.names":"` + names + `"},"TaskTemplate":{"ContainerSpec":{"Secrets":[` + secrets + `]}}}`
}
func TestSyncCreatesVersionedDockerSecret(t *testing.T) {
runner := &fakeRunner{responses: []fakeResponse{{}, {}, {}}}
provider := Docker{Runner: runner}
snapshot := vault.Snapshot{
Version: 1, VaultID: testVaultID,
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("super-secret")}},
}
states, err := provider.Sync(context.Background(), snapshot)
if err != nil {
t.Fatal(err)
}
const physical = "barkstack_tv_" + testVaultID + "_" + testRevision
if len(states) != 1 || states[0].PhysicalName != physical {
t.Fatalf("states = %#v", states)
}
create := runner.callContaining(t, "secret create")
for _, want := range []string{
"--label io.barkstack.treatvault=true",
"--label io.barkstack.treatvault.name=database_password",
physical + " -",
} {
if !strings.Contains(create.args, want) {
t.Errorf("create args = %q, missing %q", create.args, want)
}
}
if string(create.input) != "super-secret" || strings.Contains(create.args, "super-secret") {
t.Fatalf("secret was not passed exclusively on stdin: %#v", create)
}
}
func TestSyncRotatesMountedSecretWithoutChangingTarget(t *testing.T) {
const (
oldRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
oldPhysical = "barkstack_tv_" + testVaultID + "_" + oldRevision
newPhysical = "barkstack_tv_" + testVaultID + "_" + testRevision
)
runner := &fakeRunner{responses: []fakeResponse{
{output: "old-id\n"}, // secret ls
{output: secretSpecJSON("database_password", oldRevision, oldPhysical, testVaultID)}, // secret inspect
{}, // secret create (new revision)
{output: "service-id\n"}, // service ls
{output: serviceSpecJSON("database_password", mountJSON(oldPhysical, "barkstack_database_password"))}, // service inspect
{}, // service update
{err: errors.New("secret is in use by old task")}, // secret rm
}}
provider := Docker{Runner: runner}
states, err := provider.Sync(context.Background(), vault.Snapshot{
Version: 1, VaultID: testVaultID,
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("rotated")}},
})
if err != nil {
t.Fatal(err)
}
if len(states) != 1 || !states[0].InUse {
t.Fatalf("states = %#v", states)
}
update := runner.callContaining(t, "service update")
for _, want := range []string{
"--secret-rm " + oldPhysical,
"--secret-add source=" + newPhysical + ",target=barkstack_database_password",
"barkstack-pawsql",
} {
if !strings.Contains(update.args, want) {
t.Errorf("update args = %q, missing %q", update.args, want)
}
}
}
func TestSyncMountsMissingDesiredSecretOnConsumer(t *testing.T) {
const physical = "barkstack_tv_" + testVaultID + "_" + testRevision
runner := &fakeRunner{responses: []fakeResponse{
{}, // secret ls (empty vault listing)
{}, // secret create
{output: "service-id\n"},
{output: serviceSpecJSON("database_password", "")},
{}, // service update
}}
provider := Docker{Runner: runner}
states, err := provider.Sync(context.Background(), vault.Snapshot{
Version: 1, VaultID: testVaultID,
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("value")}},
})
if err != nil {
t.Fatal(err)
}
update := runner.callContaining(t, "service update")
if want := "--secret-add source=" + physical + ",target=barkstack_database_password"; !strings.Contains(update.args, want) {
t.Errorf("update args = %q, missing %q", update.args, want)
}
if !states[0].InUse {
t.Fatalf("states = %#v, want InUse", states)
}
}
func TestSyncReportsConfiguredSecretMissingFromVault(t *testing.T) {
runner := &fakeRunner{responses: []fakeResponse{
{}, // secret ls (empty)
{output: "service-id\n"},
{output: serviceSpecJSON("database_password", "")},
}}
_, err := (Docker{Runner: runner}).Sync(context.Background(), vault.Snapshot{Version: 1, VaultID: testVaultID, Secrets: map[string]vault.Record{}})
if !errors.Is(err, ErrSecretInUse) {
t.Fatalf("Sync() error = %v", err)
}
}
func TestSyncKeepsOrphanedMountAndReportsGap(t *testing.T) {
const oldPhysical = "barkstack_tv_" + testVaultID + "_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
runner := &fakeRunner{responses: []fakeResponse{
{output: "old-id\n"},
{output: secretSpecJSON("removed_secret", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", oldPhysical, testVaultID)},
{output: "service-id\n"},
{output: serviceSpecJSON("database_password", mountJSON(oldPhysical, "barkstack_database_password"))},
{err: errors.New("secret is in use")},
}}
_, err := (Docker{Runner: runner}).Sync(context.Background(), vault.Snapshot{Version: 1, VaultID: testVaultID, Secrets: map[string]vault.Record{}})
if !errors.Is(err, ErrSecretInUse) {
t.Fatalf("Sync() error = %v", err)
}
}
func secretSpecJSON(name, revision, physical, vaultID string) string {
return `{"Name":"` + physical + `","Labels":{"io.barkstack.treatvault":"true","io.barkstack.treatvault.name":"` + name + `","io.barkstack.treatvault.vault":"` + vaultID + `","io.barkstack.treatvault.revision":"` + revision + `"}}`
}
func mountJSON(source, target string) string {
return `{"SecretName":"` + source + `","File":{"Name":"` + target + `"}}`
}
type fakeResponse struct {
output string
err error
}
type fakeCall struct {
args string
input []byte
}
type fakeRunner struct {
responses []fakeResponse
calls []fakeCall
}
func (r *fakeRunner) Run(_ context.Context, input []byte, args ...string) (string, error) {
r.calls = append(r.calls, fakeCall{args: strings.Join(args, " "), input: append([]byte(nil), input...)})
if len(r.responses) == 0 {
return "", errors.New("unexpected Docker command")
}
response := r.responses[0]
r.responses = r.responses[1:]
return response.output, response.err
}
func (r *fakeRunner) callContaining(t *testing.T, prefix string) fakeCall {
t.Helper()
for _, call := range r.calls {
if strings.HasPrefix(call.args, prefix) {
return call
}
}
t.Fatalf("calls = %#v, missing %q", r.calls, prefix)
return fakeCall{}
}