feat: run TreatVault as a service image
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m2s

This commit is contained in:
2026-09-17 10:29:36 -04:00
parent 63ee4a99e6
commit 4256b7a50e
7 changed files with 147 additions and 260 deletions

View File

@@ -0,0 +1,50 @@
name: Build and Push Image
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY_HOST: registry.campbellwireless.net
IMAGE_NAME: ${{ github.repository }}
jobs:
docker-build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
git init
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git config http.extraheader "Authorization: Basic $(printf '%s' '${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}' | base64)"
git fetch --depth=1 origin "${{ github.sha }}"
git checkout FETCH_HEAD
- name: Compute image tags
id: tags
run: echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ steps.tags.outputs.sha_short }}

View File

@@ -13,4 +13,4 @@ RUN apk add --no-cache ca-certificates docker-cli age
COPY --from=build /treatvault /usr/local/bin/treatvault
EXPOSE 9090
ENTRYPOINT ["/usr/local/bin/treatvault"]
CMD ["serve", "--file", "/var/lib/treatvault/secrets.age", "--identity", "/run/secrets/barkstack_treatvault_identity", "--listen", ":9090"]
CMD ["--file", "/var/lib/treatvault/secrets.age", "--identity", "/run/secrets/barkstack_treatvault_identity", "--listen", ":9090"]

View File

@@ -10,17 +10,18 @@ TreatVault manages Barkstack's namespaced secrets. It keeps an age-encrypted sou
- Consumers mount secrets at the stable target path `barkstack_<name>`; rotations swap the mounted source and update the service, leaving a rollback path.
- Obsolete Barkstack-managed secret objects are deleted; objects still mounted by a managed service are reported in the sync status and refused for deletion via the API until their consumers are updated.
## CLI
## Service image
TreatVault is a service, not a user CLI. `registry.campbellwireless.net/barkstack/treatvault:latest` runs its HTTP API on port `9090` inside the Barkstack overlay network. It accepts only service flags:
```sh
treatvault keygen --identity ./treatvault-identity.txt
treatvault init --file ./secrets.age --identity ./treatvault-identity.txt
treatvault set --name database_password --file ./secrets.age --identity ./treatvault-identity.txt # value on stdin
treatvault list --file ./secrets.age --identity ./treatvault-identity.txt
treatvault delete --name database_password --file ./secrets.age --identity ./treatvault-identity.txt
treatvault serve --file ./secrets.age --identity ./treatvault-identity.txt --listen :9090
treatvault --file /var/lib/treatvault/secrets.age \
--identity /run/secrets/barkstack_treatvault_identity \
--listen :9090
```
On startup TreatVault creates the encrypted vault file when it is absent, using the mounted age identity. It then loads the file, reconciles Docker Swarm secrets, and exposes the API. The identity remains a Docker secret; TreatVault never exposes it through the API or UI.
## HTTP API
- `GET /barkstack/ui/manifest.json` - Barkstack plugin manifest (id `treatvault`, mount `/treatvault`).
@@ -31,7 +32,7 @@ treatvault serve --file ./secrets.age --identity ./treatvault-identity.txt --lis
## Barkstack deployment
Add a `treatvault` block to the Barkfile and run `barkstack init` on a Swarm manager:
Configure TreatVault in the Barkfile and run `barkstack init` on a Swarm manager:
```text
treatvault {
@@ -40,12 +41,12 @@ treatvault {
}
```
`barkstack init` provisions the `barkstack-treatvault` service (manager-constrained) with the encrypted file bind-mounted read-write at its host path, the Docker socket, the `barkstack_treatvault_identity` secret mounted at `/run/secrets/barkstack_treatvault_identity`, and no published ports; the console reaches it over the overlay network at `:9090`.
`barkstack init` creates `barkstack_treatvault_identity` if absent, provisions the manager-constrained `barkstack-treatvault` service, and bind-mounts the encrypted file read-write at its configured host path. The service receives only that file, the Docker socket, and its Docker-secret identity; it has no published ports. The console reaches it over the overlay network at `:9090`.
The encrypted file is safe to commit and back up. Keep its mounted Docker-secret identity outside source control. Use the TreatVault page in the Barkstack Console to create, rotate, and delete secret values; values are write-only and never returned.
Services that consume vault secrets carry the `io.barkstack.treatvault.secrets=true` label plus an `io.barkstack.treatvault.names` label listing the logical names they need; `barkstack init` labels the PawSQL service automatically. Each sync mounts the current revision at the stable `/run/secrets/barkstack_<name>` target, so consumers read the same path across rotations.
The Barkstack console automatically gains a TreatVault page (plugin id `treatvault`) for creating, rotating, and deleting secrets. Deletion is refused with `409 Conflict` while a labeled service still mounts the secret.
## Build
```sh

View File

@@ -1,3 +1,5 @@
// TreatVault is the Barkstack secret-management service. It is launched by its
// container image; operator workflows use Barkstack and the console plugin.
package main
import (
@@ -11,7 +13,6 @@ import (
"net/http"
"os"
"os/signal"
"sort"
"syscall"
"time"
@@ -21,143 +22,57 @@ import (
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
)
type options struct {
filePath string
identityPath string
listen string
dockerPath string
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
if err := run(ctx, os.Args[1:], os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "treatvault:", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
if len(args) == 0 {
return usage(stderr)
}
switch args[0] {
case "keygen":
return runKeygen(args[1:], stdout, stderr)
case "init":
return runInit(args[1:], stderr)
case "set":
return runSet(args[1:], stdin, stderr)
case "delete":
return runDelete(args[1:], stderr)
case "list":
return runList(args[1:], stdout, stderr)
case "serve":
return runServe(ctx, args[1:], stderr)
default:
return fmt.Errorf("unknown command %q", args[0])
func run(ctx context.Context, args []string, stderr io.Writer) error {
options, err := parseOptions(args, stderr)
if err != nil {
return err
}
return runServer(ctx, options, stderr)
}
func runKeygen(args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
func parseOptions(args []string, stderr io.Writer) (options, error) {
flags := flag.NewFlagSet("treatvault", flag.ContinueOnError)
flags.SetOutput(stderr)
identityPath := flags.String("identity", "treatvault-identity.txt", "age identity output path")
if err := parseNoArguments(flags, args); err != nil {
return err
options := options{}
flags.StringVar(&options.filePath, "file", "/var/lib/treatvault/secrets.age", "encrypted secret file")
flags.StringVar(&options.identityPath, "identity", "/run/secrets/barkstack_treatvault_identity", "age identity file")
flags.StringVar(&options.listen, "listen", ":9090", "HTTP listen address")
flags.StringVar(&options.dockerPath, "docker", "docker", "Docker CLI path")
if err := flags.Parse(args); err != nil {
return options, err
}
recipient, err := vault.GenerateIdentity(*identityPath)
if err != nil {
return err
if flags.NArg() != 0 {
return options, fmt.Errorf("unexpected arguments: %v", flags.Args())
}
_, _ = fmt.Fprintf(stdout, "Identity written to %s\nRecipient: %s\n", *identityPath, recipient)
return nil
return options, nil
}
func runInit(args []string, stderr io.Writer) error {
flags, filePath, identityPath := fileFlags("init", stderr)
if err := parseNoArguments(flags, args); err != nil {
return err
}
identity, err := vault.ReadIdentity(*identityPath)
func runServer(ctx context.Context, options options, stderr io.Writer) error {
store, err := vault.OpenOrInitialize(options.filePath, options.identityPath)
if err != nil {
return err
}
return vault.Initialize(*filePath, identity)
}
func runSet(args []string, stdin io.Reader, stderr io.Writer) error {
flags, filePath, identityPath := fileFlags("set", stderr)
name := flags.String("name", "", "short secret name")
valuePath := flags.String("value-file", "-", "secret value file, or - for stdin")
if err := parseNoArguments(flags, args); err != nil {
return err
}
if *name == "" {
return errors.New("--name is required")
}
value, err := readValue(*valuePath, stdin)
if err != nil {
return err
}
store, err := vault.Open(*filePath, *identityPath)
if err != nil {
return err
}
_, err = store.Set(*name, value)
return err
}
func runDelete(args []string, stderr io.Writer) error {
flags, filePath, identityPath := fileFlags("delete", stderr)
name := flags.String("name", "", "short secret name")
if err := parseNoArguments(flags, args); err != nil {
return err
}
if *name == "" {
return errors.New("--name is required")
}
store, err := vault.Open(*filePath, *identityPath)
if err != nil {
return err
}
_, err = store.Delete(*name)
return err
}
func runList(args []string, stdout, stderr io.Writer) error {
flags, filePath, identityPath := fileFlags("list", stderr)
if err := parseNoArguments(flags, args); err != nil {
return err
}
store, err := vault.Open(*filePath, *identityPath)
if err != nil {
return err
}
snapshot, err := store.Load()
if err != nil {
return err
}
names := make([]string, 0, len(snapshot.Secrets))
for name := range snapshot.Secrets {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
_, _ = fmt.Fprintln(stdout, name)
}
return nil
}
func runServe(ctx context.Context, args []string, stderr io.Writer) error {
flags, filePath, identityPath := fileFlags("serve", stderr)
listen := flags.String("listen", ":9090", "HTTP listen address")
dockerPath := flags.String("docker", "docker", "Docker CLI path")
if err := parseNoArguments(flags, args); err != nil {
return err
}
store, err := vault.Open(*filePath, *identityPath)
if err != nil {
return err
}
secretManager := manager.New(store, provider.Docker{Runner: provider.ExecRunner{Path: *dockerPath}}, 500*time.Millisecond)
secretManager := manager.New(store, provider.Docker{Runner: provider.ExecRunner{Path: options.dockerPath}}, 500*time.Millisecond)
go secretManager.Run(ctx)
listener, err := net.Listen("tcp", *listen)
listener, err := net.Listen("tcp", options.listen)
if err != nil {
return fmt.Errorf("listen on %s: %w", *listen, err)
return fmt.Errorf("listen on %s: %w", options.listen, err)
}
logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
logger.Info("TreatVault listening", "address", listener.Addr().String())
@@ -183,39 +98,3 @@ func runServe(ctx context.Context, args []string, stderr io.Writer) error {
return err
}
}
func fileFlags(name string, stderr io.Writer) (*flag.FlagSet, *string, *string) {
flags := flag.NewFlagSet(name, flag.ContinueOnError)
flags.SetOutput(stderr)
filePath := flags.String("file", "secrets.age", "encrypted secret file")
identityPath := flags.String("identity", "treatvault-identity.txt", "age identity file")
return flags, filePath, identityPath
}
func parseNoArguments(flags *flag.FlagSet, args []string) error {
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %v", flags.Args())
}
return nil
}
func readValue(path string, stdin io.Reader) ([]byte, error) {
if path == "-" {
return io.ReadAll(io.LimitReader(stdin, 500*1024+1))
}
return os.ReadFile(path)
}
func usage(stderr io.Writer) error {
_, _ = fmt.Fprintln(stderr, "usage:")
_, _ = fmt.Fprintln(stderr, " treatvault keygen [--identity FILE]")
_, _ = fmt.Fprintln(stderr, " treatvault init [--file FILE] [--identity FILE]")
_, _ = fmt.Fprintln(stderr, " treatvault set --name NAME [--value-file FILE|-] [--file FILE] [--identity FILE]")
_, _ = fmt.Fprintln(stderr, " treatvault delete --name NAME [--file FILE] [--identity FILE]")
_, _ = fmt.Fprintln(stderr, " treatvault list [--file FILE] [--identity FILE]")
_, _ = fmt.Fprintln(stderr, " treatvault serve [--file FILE] [--identity FILE] [--listen :9090] [--docker docker]")
return errors.New("a command is required")
}

View File

@@ -2,85 +2,35 @@ package main
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
"filippo.io/age"
)
func TestCLIEndToEndWithoutDocker(t *testing.T) {
directory := t.TempDir()
filePath := filepath.Join(directory, "secrets.age")
identityPath := filepath.Join(directory, "identity.txt")
var stdout, stderr strings.Builder
mustRun(t, []string{"keygen", "--identity", identityPath}, nil, &stdout, &stderr)
if !strings.Contains(stdout.String(), "Recipient: age1") {
t.Fatalf("keygen output = %q", stdout.String())
}
mustRun(t, []string{"init", "--file", filePath, "--identity", identityPath}, nil, &strings.Builder{}, &strings.Builder{})
mustRun(t, []string{"set", "--name", "database_password", "--value-file", "-", "--file", filePath, "--identity", identityPath}, strings.NewReader("correct horse battery staple"), &strings.Builder{}, &strings.Builder{})
var list strings.Builder
mustRun(t, []string{"list", "--file", filePath, "--identity", identityPath}, nil, &list, &strings.Builder{})
if got := strings.TrimSpace(list.String()); got != "database_password" {
t.Fatalf("list = %q", got)
}
snapshot, err := vault.Open(filePath, identityPath)
func TestParseOptionsUsesContainerDefaults(t *testing.T) {
options, err := parseOptions(nil, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
loaded, err := snapshot.Load()
if err != nil {
t.Fatal(err)
}
if string(loaded.Secrets["database_password"].Value) != "correct horse battery staple" {
t.Fatalf("stored value = %q", loaded.Secrets["database_password"].Value)
}
mustRun(t, []string{"delete", "--name", "database_password", "--file", filePath, "--identity", identityPath}, nil, &strings.Builder{}, &strings.Builder{})
loaded, err = snapshot.Load()
if err != nil {
t.Fatal(err)
}
if len(loaded.Secrets) != 0 {
t.Fatalf("secrets after delete = %#v", loaded.Secrets)
if options.filePath != "/var/lib/treatvault/secrets.age" || options.identityPath != "/run/secrets/barkstack_treatvault_identity" || options.listen != ":9090" || options.dockerPath != "docker" {
t.Fatalf("options = %#v", options)
}
}
func TestSetRejectsEmptyValue(t *testing.T) {
directory := t.TempDir()
filePath := filepath.Join(directory, "secrets.age")
identityPath := filepath.Join(directory, "identity.txt")
identity, err := age.GenerateX25519Identity()
func TestParseOptionsAcceptsServiceOverrides(t *testing.T) {
options, err := parseOptions([]string{"--file", "/vault/secrets.age", "--identity", "/identity", "--listen", "127.0.0.1:9090", "--docker", "/usr/bin/docker"}, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(identityPath, []byte(identity.String()), 0o600); err != nil {
t.Fatal(err)
}
if err := vault.Initialize(filePath, identity); err != nil {
t.Fatal(err)
}
var stderr strings.Builder
err = run(context.Background(), []string{"set", "--name", "database_password", "--value-file", "-", "--file", filePath, "--identity", identityPath}, strings.NewReader(""), &strings.Builder{}, &stderr)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("set empty value error = %v", err)
if options.filePath != "/vault/secrets.age" || options.identityPath != "/identity" || options.listen != "127.0.0.1:9090" || options.dockerPath != "/usr/bin/docker" {
t.Fatalf("options = %#v", options)
}
}
func mustRun(t *testing.T, args []string, stdin *strings.Reader, stdout, stderr *strings.Builder) {
t.Helper()
var reader io.Reader = bytes.NewReader(nil)
if stdin != nil {
reader = stdin
}
if err := run(context.Background(), args, reader, stdout, stderr); err != nil {
t.Fatalf("run(%v) error = %v", args, err)
func TestParseOptionsRejectsLegacyCommands(t *testing.T) {
for _, command := range []string{"keygen", "init", "set", "delete", "list", "serve"} {
_, err := parseOptions([]string{command}, &bytes.Buffer{})
if err == nil || !strings.Contains(err.Error(), "unexpected arguments") {
t.Errorf("parseOptions(%q) error = %v", command, err)
}
}
}

View File

@@ -24,7 +24,10 @@ const (
maxDocumentBytes = 16 * 1024 * 1024
)
var ErrSecretNotFound = errors.New("secret not found")
var (
ErrSecretNotFound = errors.New("secret not found")
ErrAlreadyInitialized = errors.New("encrypted file already exists")
)
type Record struct {
Revision string `json:"revision"`
@@ -49,12 +52,26 @@ type Store struct {
mu sync.Mutex
}
func Open(path, identityPath string) (*Store, error) {
// OpenOrInitialize opens an encrypted file, creating an empty vault when it
// does not exist. A TreatVault service owns this bootstrap path; operators
// never need a separate vault-management executable.
func OpenOrInitialize(path, identityPath string) (*Store, error) {
identity, err := ReadIdentity(identityPath)
if err != nil {
return nil, err
}
store := &Store{path: path, identity: identity, recipient: identity.Recipient()}
store := New(path, identity)
if _, err := os.Stat(path); err == nil {
if _, err := store.Load(); err != nil {
return nil, err
}
return store, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("stat encrypted file: %w", err)
}
if err := Initialize(path, identity); err != nil && !errors.Is(err, ErrAlreadyInitialized) {
return nil, err
}
if _, err := store.Load(); err != nil {
return nil, err
}
@@ -77,25 +94,9 @@ func ReadIdentity(path string) (*age.X25519Identity, error) {
return identity, nil
}
func GenerateIdentity(path string) (string, error) {
identity, err := age.GenerateX25519Identity()
if err != nil {
return "", fmt.Errorf("generate age identity: %w", err)
}
if _, err := os.Stat(path); err == nil {
return "", fmt.Errorf("identity file %q already exists", path)
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err := writeFileAtomic(path, []byte(identity.String()+"\n"), 0o600); err != nil {
return "", fmt.Errorf("write age identity: %w", err)
}
return identity.Recipient().String(), nil
}
func Initialize(path string, identity *age.X25519Identity) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("encrypted file %q already exists", path)
return fmt.Errorf("%w: %q", ErrAlreadyInitialized, path)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}

View File

@@ -86,23 +86,29 @@ func TestStoreRejectsWrongIdentityAndInvalidValues(t *testing.T) {
}
}
func TestGenerateIdentityDoesNotOverwrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "identity.txt")
recipient, err := GenerateIdentity(path)
func TestOpenOrInitializeCreatesReusableEmptyVault(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
if recipient == "" {
t.Fatal("recipient is empty")
directory := t.TempDir()
identityPath := filepath.Join(directory, "identity.txt")
if err := os.WriteFile(identityPath, []byte(identity.String()), 0o600); err != nil {
t.Fatal(err)
}
if _, err := GenerateIdentity(path); err == nil {
t.Fatal("GenerateIdentity() overwrote existing identity")
}
info, err := os.Stat(path)
path := filepath.Join(directory, "state", "secrets.age")
store, err := OpenOrInitialize(path, identityPath)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("identity permissions = %o", info.Mode().Perm())
snapshot, err := store.Load()
if err != nil {
t.Fatal(err)
}
if len(snapshot.Secrets) != 0 {
t.Fatalf("initial secrets = %#v", snapshot.Secrets)
}
if _, err := OpenOrInitialize(path, identityPath); err != nil {
t.Fatalf("OpenOrInitialize existing vault: %v", err)
}
}