carlos env/secrets: bucket-backed app config, encrypted at rest (#9)
Per-app config plane: internal/appconfig (manifest, ECDH-P256+HKDF+AES-256-GCM seal/open, CAS store on release.Store, EnvironmentFile rendering, adopt-pass Sync), carlos env set/unset/list and carlos secrets set/unset/list/genkey, sync wired into the root carlos adopt path ahead of release adoption. Secrets are sealed client-side with CARLOS_SECRETS_PUBKEY (write-only credentials); CARLOS_SECRETS_KEY on a host's host.env enables materialization into /etc/carlos/apps/<route.Host>.env for unit-backed routes. Inert until configured; an absent manifest never touches hand-staged files; an empty registry never reaps. Includes golden-vector crypto tests, CAS race tests, sync convergence/migration tests, and CLI e2e tests.
17 files changed,
+3900
−0
README.md+35 −0appconfig_cli.go+249 −0docs/superpowers/plans/2026-08-02-app-config-secrets.md+1728 −0docs/superpowers/specs/2026-08-02-app-config-secrets-design.md+225 −0e2e_test.go+126 −0internal/appconfig/appconfig.go+50 −0internal/appconfig/appconfig_test.go+26 −0internal/appconfig/crypto.go+159 −0internal/appconfig/crypto_test.go+114 −0internal/appconfig/materialize.go+67 −0internal/appconfig/materialize_test.go+86 −0internal/appconfig/store.go+162 −0internal/appconfig/store_test.go+173 −0internal/appconfig/sync.go+197 −0internal/appconfig/sync_test.go+477 −0internal/host/agent.go+20 −0main.go+6 −0
diff --git a/README.md b/README.md| index 883ce54b2..f7dbddf35 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -152,6 +152,41 @@ fallback code — is a separate operator step, gated on verification: every |
| channel pointer must resolve from the qualified keys with the fallback |
| disabled, and one adopt pass must be a no-op, first. |
| +## App config & secrets |
| + |
| +Per-app config lives in the deployment bucket |
| +(`apps/<acct>/<app>/config.json`): plain vars via `carlos env`, secrets |
| +sealed client-side via `carlos secrets` — the bucket never sees plaintext. |
| + |
| + carlos secrets genkey # mint CARLOS_SECRETS_KEY / CARLOS_SECRETS_PUBKEY |
| + carlos env set -app keymail LOG_LEVEL=debug |
| + carlos secrets set -app keymail SES_SECRET=... # sealed with CARLOS_SECRETS_PUBKEY before upload |
| + carlos secrets list -app keymail # key names only — a value is never readable again |
| + |
| +The keypair is the mirror image of the release signing pair: |
| +`CARLOS_SECRETS_PUBKEY` only encrypts and can be held widely (CI, laptops |
| +— write-only credentials); `CARLOS_SECRETS_KEY` decrypts and belongs in |
| +exactly two places, your secret manager and every host's |
| +`/etc/carlos/host.env`. |
| + |
| +Hosts converge during the hourly root `carlos adopt` pass: with |
| +`CARLOS_SECRETS_KEY` set, each pass materializes the manifest into |
| +`/etc/carlos/apps/<route.Host>.env` (the file `carlos-app@.service` |
| +already reads) and restarts routes whose file changed. With it unset, |
| +config sync is inert — the same convention as every other optional loop. |
| + |
| +Operational notes: |
| + |
| +- Config sync reaches **unit-backed routes only** (`Backing: "unit"`). |
| + Exec-backed routes read `/data/<host>.env` and keep their hand-staged |
| + files untouched; the adopt log says so per pass. |
| +- Migrate an app's config into the bucket *before* enabling |
| + `CARLOS_SECRETS_KEY` on its hosts: once a manifest object exists for an |
| + app, the bucket is authoritative for its env files (fully unset ⇒ |
| + converges to empty). An app with no manifest at all is left alone. |
| +- Values are write-only. Rotating the keypair means re-entering every |
| + secret of every app from its source of record — there is no rewrap yet. |
| + |
| ## Domains |
| Every app has domains as a first-class concept: a **platform domain** it is |
diff --git a/appconfig_cli.go b/appconfig_cli.go| new file mode 100644 |
| index 000000000..a971d9baf |
| --- /dev/null |
| +++ b/appconfig_cli.go |
| @@ -0,0 +1,249 @@ |
| +// The `carlos env` and `carlos secrets` verbs: per-app config, backed by |
| +// internal/appconfig. Dispatch shape mirrors RunAccounts/RunAuth (accounts.go, |
| +// auth.go) — a flat switch on the first arg. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "sort" |
| + "strings" |
| + "text/tabwriter" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +const envUsage = `usage: carlos env <command> |
| + |
| +commands: |
| + set -app <a> KEY=value [KEY2=value2 ...] set one or more plain config vars |
| + unset -app <a> KEY [KEY2 ...] remove config vars |
| + list -app <a> list an app's config vars |
| +` |
| + |
| +// RunEnv is the `carlos env` verb. |
| +func RunEnv(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, envUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "set": |
| + runEnvSet(args[1:]) |
| + case "unset": |
| + runEnvUnset(args[1:]) |
| + case "list": |
| + runEnvList(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, envUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// parseKV turns ["KEY=value", "KEY2=value2"] into a map, refusing anything |
| +// without an "=" or with an empty key. Shared by `env set` and `secrets set`. |
| +func parseKV(args []string) (map[string]string, error) { |
| + if len(args) == 0 { |
| + return nil, fmt.Errorf("at least one KEY=value pair is required") |
| + } |
| + out := map[string]string{} |
| + for i, a := range args { |
| + k, v, ok := strings.Cut(a, "=") |
| + if !ok || k == "" { |
| + // Named by position, never echoed: a mistyped `secrets set` |
| + // argument IS a secret, and this message lands in stderr and |
| + // shell scrollback. |
| + return nil, fmt.Errorf("argument %d is not a KEY=value pair", i+1) |
| + } |
| + out[k] = v |
| + } |
| + return out, nil |
| +} |
| + |
| +func runEnvSet(args []string) { |
| + fs := flag.NewFlagSet("env set", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos env set -app <a> KEY=value [KEY2=value2 ...]") |
| + } |
| + kv, err := parseKV(fs.Args()) |
| + if err != nil { |
| + log.Fatalf("env set: %v", err) |
| + } |
| + if err := appconfig.SetEnv(context.Background(), openStore(), acctOf(), *app, kv); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("set %d env var(s) for %s\n", len(kv), *app) |
| +} |
| + |
| +func runEnvUnset(args []string) { |
| + fs := flag.NewFlagSet("env unset", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() == 0 { |
| + log.Fatal("usage: carlos env unset -app <a> KEY [KEY2 ...]") |
| + } |
| + if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("unset %d var(s) for %s\n", fs.NArg(), *app) |
| +} |
| + |
| +func runEnvList(args []string) { |
| + fs := flag.NewFlagSet("env list", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos env list -app <a>") |
| + } |
| + m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + var keys []string |
| + for k, v := range m.Vars { |
| + if v.Kind == "env" { |
| + keys = append(keys, k) |
| + } |
| + } |
| + sort.Strings(keys) |
| + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, "KEY\tVALUE") |
| + for _, k := range keys { |
| + fmt.Fprintf(w, "%s\t%s\n", k, m.Vars[k].Value) |
| + } |
| + w.Flush() |
| +} |
| + |
| +const secretsUsage = `usage: carlos secrets <command> |
| + |
| +commands: |
| + set -app <a> KEY=value [KEY2=value2 ...] seal and set one or more secrets |
| + unset -app <a> KEY [KEY2 ...] remove secrets |
| + list -app <a> list a secret's key names only — never values |
| + genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair |
| +` |
| + |
| +// RunSecrets is the `carlos secrets` verb. |
| +func RunSecrets(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, secretsUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "set": |
| + runSecretsSet(args[1:]) |
| + case "unset": |
| + runSecretsUnset(args[1:]) |
| + case "list": |
| + runSecretsList(args[1:]) |
| + case "genkey": |
| + runSecretsGenkey(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, secretsUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// secretsPubKey reads CARLOS_SECRETS_PUBKEY — distinct from cli.go's |
| +// publicKey()/signingKey(), which are the unrelated release signing pair. |
| +func secretsPubKey() *ecdh.PublicKey { |
| + k, err := appconfig.ParsePublicKey(os.Getenv("CARLOS_SECRETS_PUBKEY"), "CARLOS_SECRETS_PUBKEY") |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + return k |
| +} |
| + |
| +func runSecretsSet(args []string) { |
| + fs := flag.NewFlagSet("secrets set", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos secrets set -app <a> KEY=value [KEY2=value2 ...]") |
| + } |
| + kv, err := parseKV(fs.Args()) |
| + if err != nil { |
| + log.Fatalf("secrets set: %v", err) |
| + } |
| + pub := secretsPubKey() |
| + sealed := make(map[string]string, len(kv)) |
| + for k, v := range kv { |
| + // Validate here, while the plaintext is still in hand — after Seal |
| + // the value is opaque forever (`secrets list` never shows values), so |
| + // a bad one would only surface as a render failure in the hourly |
| + // adopt log, stalling the app's entire config with nothing for the |
| + // operator to inspect. |
| + if err := appconfig.ValidVarValue(v); err != nil { |
| + log.Fatalf("secrets set: %s: %v", k, err) |
| + } |
| + env, err := appconfig.Seal(pub, []byte(v)) |
| + if err != nil { |
| + log.Fatalf("seal %s: %v", k, err) |
| + } |
| + sealed[k] = env |
| + } |
| + if err := appconfig.SetSecret(context.Background(), openStore(), acctOf(), *app, sealed); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("set %d secret(s) for %s\n", len(kv), *app) |
| +} |
| + |
| +func runSecretsUnset(args []string) { |
| + fs := flag.NewFlagSet("secrets unset", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() == 0 { |
| + log.Fatal("usage: carlos secrets unset -app <a> KEY [KEY2 ...]") |
| + } |
| + if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("unset %d secret(s) for %s\n", fs.NArg(), *app) |
| +} |
| + |
| +func runSecretsList(args []string) { |
| + fs := flag.NewFlagSet("secrets list", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos secrets list -app <a>") |
| + } |
| + m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + var keys []string |
| + for k, v := range m.Vars { |
| + if v.Kind == "secret" { |
| + keys = append(keys, k) |
| + } |
| + } |
| + sort.Strings(keys) |
| + for _, k := range keys { |
| + fmt.Println(k) |
| + } |
| +} |
| + |
| +func runSecretsGenkey(args []string) { |
| + priv, pub, err := appconfig.GenerateKey() |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("CARLOS_SECRETS_KEY=%s\nCARLOS_SECRETS_PUBKEY=%s\n", priv, pub) |
| + fmt.Fprintln(os.Stderr, "store the KEY in your secret manager and in every host's host.env; "+ |
| + "the PUBKEY can be held widely — it only encrypts") |
| + fmt.Fprintln(os.Stderr, "note: config sync currently reaches unit-backed routes (carlos-app@) only; "+ |
| + "exec-backed routes keep their hand-staged env files") |
| +} |
diff --git a/docs/superpowers/plans/2026-08-02-app-config-secrets.md b/docs/superpowers/plans/2026-08-02-app-config-secrets.md| new file mode 100644 |
| index 000000000..33fe068fb |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-02-app-config-secrets.md |
| @@ -0,0 +1,1728 @@ |
| +# App config and secrets (`carlos env` / `carlos secrets`) Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Give operators a bucket-backed `carlos env`/`carlos secrets` CLI for per-app runtime config, with secret values sealed client-side, replacing the manually-staged `/etc/carlos/apps/<host>.env`. |
| + |
| +**Architecture:** A new `internal/appconfig` package (types, an ECDH-P256/HKDF/AES-GCM seal primitive, CAS-guarded bucket storage on top of `internal/release`'s `Store`, env-file rendering, and an adopt-pass-shaped `Sync`), a new `carlos env`/`carlos secrets` CLI surface in `main`, and one call site wiring `Sync` into the existing root-run `carlos adopt` path. |
| + |
| +**Tech Stack:** Go 1.26, `crypto/ecdh` (stdlib), `golang.org/x/crypto/hkdf` (already a direct dependency), `internal/release.Store` (existing CAS bucket abstraction), `internal/registry` (existing route store). |
| + |
| +## Global Constraints |
| + |
| +- Go 1.26 (per `go.mod`); no new `go.mod` dependencies — `crypto/ecdh` is stdlib since Go 1.20, `golang.org/x/crypto` is already required at v0.52.0. |
| +- Bucket keys are account-scoped: `apps/<acct>/<app>/config.json`, matching `release.AppPrefix` — app names are unique only within an account (spec §3). |
| +- One manifest per app, shared across every channel/route it runs (spec §1) — no per-channel overrides in this pass. |
| +- Secret values are never logged, printed, or round-tripped through the CLI once set — `secrets list` prints key names only (spec §2). |
| +- `CARLOS_SECRETS_KEY` (private, decrypts) must reach every host's `host.env`; `CARLOS_SECRETS_PUBKEY` (public, encrypts) is safe to hold widely (spec §4) — this is the mirror image of `CARLOS_RELEASE_KEY`'s sensitivity split, not the same shape. |
| +- Config sync writes `/etc/carlos/apps/<route.Host>.env` and restarts `carlos-app@<route.Host>.service` — keyed by route **Host**, not by app name, since one app can run several simultaneous routes (spec §5). |
| +- Config sync must run only on the root-privileged `carlos adopt` path (`RunAdopt` in `internal/host/agent.go`, fired by `carlos-adopt.timer`), never the in-process loop inside `carlos agent`/`carlos-edge.service` — that unit runs unprivileged under `ProtectSystem=strict` with `ReadWritePaths=/data /run/carlos` only and cannot write `/etc/carlos/apps` (spec §5, corrected during planning — see `docs/superpowers/specs/2026-08-02-app-config-secrets-design.md`'s commit history for why). |
| +- No per-channel authorization beyond bucket-write IAM (spec §6) — nothing here adds an access-control layer. |
| + |
| +--- |
| + |
| +## Task 1: Manifest types, bucket key, var-name validation |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/appconfig.go` |
| +- Test: `internal/appconfig/appconfig_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing new (no dependency on `internal/release` yet — `Key` reproduces `release.AppPrefix`'s shape inline since Task 1 has no reason to import `release` yet; Task 3 will use `release.AppPrefix` directly once it needs the `Store` type anyway). |
| +- Produces: `type Manifest struct{ Schema int; App string; Vars map[string]Var }`, `type Var struct{ Kind, Value, Envelope string }`, `func Key(acct, app string) string`, `func ValidVarName(k string) bool` — every later task in this package builds on these. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/appconfig/appconfig_test.go |
| +package appconfig |
| + |
| +import "testing" |
| + |
| +func TestKeyIsAccountScoped(t *testing.T) { |
| + got := Key("bab", "keymail") |
| + want := "apps/bab/keymail/config.json" |
| + if got != want { |
| + t.Fatalf("Key(bab, keymail) = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestValidVarName(t *testing.T) { |
| + valid := []string{"LOG_LEVEL", "_FOO", "A", "SES_SECRET_2", "lower_case"} |
| + invalid := []string{"", "1FOO", "has-hyphen", "has space", "has=equals"} |
| + for _, k := range valid { |
| + if !ValidVarName(k) { |
| + t.Errorf("ValidVarName(%q) = false, want true", k) |
| + } |
| + } |
| + for _, k := range invalid { |
| + if ValidVarName(k) { |
| + t.Errorf("ValidVarName(%q) = true, want false", k) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/appconfig/... -run 'TestKeyIsAccountScoped|TestValidVarName' -v` |
| +Expected: FAIL — `undefined: Key`, `undefined: ValidVarName` (the package doesn't exist yet). |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +```go |
| +// internal/appconfig/appconfig.go |
| + |
| +// Package appconfig is the per-app config/secrets plane: a bucket-backed |
| +// manifest of plain env vars and sealed secrets (internal/release parallel — |
| +// see its doc comment — sharing only the underlying Store), a native |
| +// seal/open primitive, and the host-side render/sync that materializes a |
| +// manifest into the EnvironmentFile carlos-app@.service already reads. |
| +// |
| +// Bucket layout (deployment bucket, account-scoped like every other app key |
| +// internal/release writes): |
| +// |
| +// apps/<acct>/<app>/config.json CAS-guarded config manifest |
| +package appconfig |
| + |
| +import "regexp" |
| + |
| +// Manifest holds every config var and secret an app has set, together so a |
| +// host materializes both with one bucket fetch. |
| +type Manifest struct { |
| + Schema int `json:"schema"` // 1 |
| + App string `json:"app"` |
| + Vars map[string]Var `json:"vars"` |
| +} |
| + |
| +// Var is one config var or secret. Kind selects which of Value/Envelope is |
| +// populated: "env" vars are plaintext (Value); "secret" vars are sealed |
| +// client-side before upload (Envelope, base64 — see crypto.go's Seal). |
| +type Var struct { |
| + Kind string `json:"kind"` |
| + Value string `json:"value,omitempty"` |
| + Envelope string `json:"envelope,omitempty"` |
| +} |
| + |
| +// Key is the bucket key of app's config manifest, account-scoped exactly |
| +// like release.AppPrefix (app names are unique only within an account). |
| +func Key(acct, app string) string { |
| + return "apps/" + acct + "/" + app + "/config.json" |
| +} |
| + |
| +// varNameRe: a leading letter or underscore, then letters/digits/underscore |
| +// — the shape every shell and systemd's EnvironmentFile parser expect a |
| +// variable name to have. Case is not restricted (systemd doesn't care). |
| +var varNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) |
| + |
| +// ValidVarName reports whether k is usable as an env/secret var name. |
| +func ValidVarName(k string) bool { return varNameRe.MatchString(k) } |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/appconfig/... -run 'TestKeyIsAccountScoped|TestValidVarName' -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/appconfig.go internal/appconfig/appconfig_test.go |
| +git commit -m "appconfig: manifest types, account-scoped bucket key, var-name validation" |
| +``` |
| + |
| +--- |
| + |
| +## Task 2: Crypto primitive — GenerateKey/Parse\*/Seal/Open |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/crypto.go` |
| +- Test: `internal/appconfig/crypto_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from Task 1 (crypto is a standalone primitive; `Manifest`/`Var` don't appear here). |
| +- Produces: `func GenerateKey() (privB64, pubB64 string, err error)`, `func ParsePrivateKey(b64, src string) (*ecdh.PrivateKey, error)`, `func ParsePublicKey(b64, src string) (*ecdh.PublicKey, error)`, `func Seal(pub *ecdh.PublicKey, plaintext []byte) (string, error)`, `func Open(priv *ecdh.PrivateKey, envelopeB64 string) ([]byte, error)` — Task 4 (`RenderEnv`) calls `Open`; the CLI (Tasks 6/7) calls all five. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/appconfig/crypto_test.go |
| +package appconfig |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdh" |
| + "encoding/base64" |
| + "testing" |
| +) |
| + |
| +// fixedScalar returns a 32-byte P-256 scalar for deterministic test keys — |
| +// never used outside tests. Byte 0 is pinned to 0x01 to keep it a valid |
| +// scalar well under the P-256 order regardless of the fill byte. |
| +func fixedScalar(b byte) []byte { |
| + s := make([]byte, 32) |
| + for i := range s { |
| + s[i] = b |
| + } |
| + s[0] = 0x01 |
| + return s |
| +} |
| + |
| +// TestSealOpenGoldenVector pins the wire format (ephPub(65) || nonce(12) || |
| +// ciphertext, base64) against a fixed ephemeral key and nonce so a future |
| +// change to field order, sizes, or the HKDF info string breaks loudly |
| +// instead of silently producing envelopes older code can't read. The |
| +// expected value was computed by running this exact algorithm once, not |
| +// invented — see the plan's Task 2 for how. |
| +func TestSealOpenGoldenVector(t *testing.T) { |
| + recipientPriv, err := ecdh.P256().NewPrivateKey(fixedScalar(0x11)) |
| + if err != nil { |
| + t.Fatalf("recipient key: %v", err) |
| + } |
| + ephPriv, err := ecdh.P256().NewPrivateKey(fixedScalar(0x22)) |
| + if err != nil { |
| + t.Fatalf("ephemeral key: %v", err) |
| + } |
| + nonce := make([]byte, 12) |
| + for i := range nonce { |
| + nonce[i] = byte(i + 1) |
| + } |
| + plaintext := []byte("s3kr1t-value") |
| + |
| + const wantEnvelope = "BKtNPVwwBQiJVQNbG6mW7bJMPFZfpNRwU1bRNeKQcV29Pzvpof1ONhvRxqXEJnk/4+qdPPRg1qWtCl0sgEsTaUgBAgMEBQYHCAkKCwwPFyVKJUipEHkmgC9Wpeogyu3CLH/32CzrKb0y" |
| + |
| + got, err := sealWith(ephPriv, nonce, recipientPriv.PublicKey(), plaintext) |
| + if err != nil { |
| + t.Fatalf("sealWith: %v", err) |
| + } |
| + if got != wantEnvelope { |
| + t.Fatalf("envelope drifted from the pinned wire format:\n got: %s\n want: %s", got, wantEnvelope) |
| + } |
| + opened, err := Open(recipientPriv, got) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + if !bytes.Equal(opened, plaintext) { |
| + t.Fatalf("Open = %q, want %q", opened, plaintext) |
| + } |
| +} |
| + |
| +func TestSealOpenRoundTripRandomKeys(t *testing.T) { |
| + priv, pub, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + pubKey, err := ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + envelope, err := Seal(pubKey, []byte("hello")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + got, err := Open(privKey, envelope) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + if string(got) != "hello" { |
| + t.Fatalf("Open = %q, want hello", got) |
| + } |
| +} |
| + |
| +func TestOpenRejectsTamperedCiphertext(t *testing.T) { |
| + priv, pub, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + pubKey, err := ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + envelope, err := Seal(pubKey, []byte("hello")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + raw, err := base64.StdEncoding.DecodeString(envelope) |
| + if err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + raw[len(raw)-1] ^= 0xFF // flip a ciphertext byte |
| + tampered := base64.StdEncoding.EncodeToString(raw) |
| + if _, err := Open(privKey, tampered); err == nil { |
| + t.Fatal("Open accepted tampered ciphertext, want an error") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/appconfig/... -run 'TestSealOpen|TestOpenRejects' -v` |
| +Expected: FAIL — `undefined: sealWith`, `undefined: Open`, `undefined: GenerateKey`, etc. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +```go |
| +// internal/appconfig/crypto.go |
| + |
| +// Envelope format and key handling for per-app secrets: ECDH-P256 ephemeral |
| +// -> HKDF-SHA256 -> AES-256-GCM, wire shape ephPub(65) || nonce(12) || |
| +// ciphertext, base64-encoded for storage in a Manifest Var's Envelope |
| +// field. Mirrors the shape the framework's crypto core plans for its own |
| +// Seal/Open (docs/superpowers/specs/2026-08-01-carlos-framework-design.md |
| +// §6), reimplemented natively here since platform cannot import the |
| +// framework repo (platform is the substrate the framework builds on). |
| +// |
| +// Key sensitivity is the MIRROR IMAGE of CARLOS_RELEASE_KEY's: there, the |
| +// private half stays off every host and only the public half is |
| +// fleet-wide; here, the private half (CARLOS_SECRETS_KEY) must reach every |
| +// host that decrypts, and the public half (CARLOS_SECRETS_PUBKEY) is the |
| +// one safe to hold widely — it can only encrypt, never decrypt. |
| +package appconfig |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "strings" |
| + |
| + "golang.org/x/crypto/hkdf" |
| +) |
| + |
| +// randReader is a var so tests can inject determinism for golden vectors. |
| +var randReader io.Reader = rand.Reader |
| + |
| +// hkdfInfo domain-separates this derivation from any other use of the same |
| +// shared secret, the same convention the framework crypto core's Sign |
| +// context strings use for its own purposes. |
| +const hkdfInfo = "carlos-secrets-v1" |
| + |
| +const ( |
| + ephLen = 65 // uncompressed P-256 point |
| + nonceLen = 12 |
| +) |
| + |
| +// GenerateKey mints a fresh P-256 ECDH pair: the base64 private key (the |
| +// operator's secret — must reach every host's host.env as |
| +// CARLOS_SECRETS_KEY) and the base64 public key (CARLOS_SECRETS_PUBKEY, |
| +// safe to hold widely). |
| +func GenerateKey() (privB64, pubB64 string, err error) { |
| + priv, err := ecdh.P256().GenerateKey(randReader) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + return base64.StdEncoding.EncodeToString(priv.Bytes()), |
| + base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), nil |
| +} |
| + |
| +// ParsePrivateKey turns a base64 P-256 scalar — from CARLOS_SECRETS_KEY — |
| +// into the decryption key. src only names the origin in the error. |
| +func ParsePrivateKey(b64, src string) (*ecdh.PrivateKey, error) { |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: invalid base64: %w", src, err) |
| + } |
| + k, err := ecdh.P256().NewPrivateKey(raw) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", src, err) |
| + } |
| + return k, nil |
| +} |
| + |
| +// ParsePublicKey turns a base64 P-256 point — from CARLOS_SECRETS_PUBKEY — |
| +// into the encryption key. |
| +func ParsePublicKey(b64, src string) (*ecdh.PublicKey, error) { |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: invalid base64: %w", src, err) |
| + } |
| + k, err := ecdh.P256().NewPublicKey(raw) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", src, err) |
| + } |
| + return k, nil |
| +} |
| + |
| +// Seal encrypts plaintext for pub with a fresh ephemeral key and nonce. |
| +func Seal(pub *ecdh.PublicKey, plaintext []byte) (string, error) { |
| + eph, err := ecdh.P256().GenerateKey(randReader) |
| + if err != nil { |
| + return "", err |
| + } |
| + nonce := make([]byte, nonceLen) |
| + if _, err := io.ReadFull(randReader, nonce); err != nil { |
| + return "", err |
| + } |
| + return sealWith(eph, nonce, pub, plaintext) |
| +} |
| + |
| +// sealWith is Seal with the ephemeral key and nonce supplied — the seam the |
| +// golden-vector test uses for a deterministic envelope. |
| +func sealWith(eph *ecdh.PrivateKey, nonce []byte, pub *ecdh.PublicKey, plaintext []byte) (string, error) { |
| + shared, err := eph.ECDH(pub) |
| + if err != nil { |
| + return "", err |
| + } |
| + gcm, err := gcmFor(shared) |
| + if err != nil { |
| + return "", err |
| + } |
| + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) |
| + out := make([]byte, 0, ephLen+nonceLen+len(ciphertext)) |
| + out = append(out, eph.PublicKey().Bytes()...) |
| + out = append(out, nonce...) |
| + out = append(out, ciphertext...) |
| + return base64.StdEncoding.EncodeToString(out), nil |
| +} |
| + |
| +// Open decrypts an envelope Seal produced, using priv. |
| +func Open(priv *ecdh.PrivateKey, envelopeB64 string) ([]byte, error) { |
| + raw, err := base64.StdEncoding.DecodeString(envelopeB64) |
| + if err != nil { |
| + return nil, fmt.Errorf("envelope: invalid base64: %w", err) |
| + } |
| + if len(raw) < ephLen+nonceLen { |
| + return nil, errors.New("envelope: too short") |
| + } |
| + ephPub, err := ecdh.P256().NewPublicKey(raw[:ephLen]) |
| + if err != nil { |
| + return nil, fmt.Errorf("envelope: %w", err) |
| + } |
| + nonce := raw[ephLen : ephLen+nonceLen] |
| + ciphertext := raw[ephLen+nonceLen:] |
| + shared, err := priv.ECDH(ephPub) |
| + if err != nil { |
| + return nil, fmt.Errorf("envelope: %w", err) |
| + } |
| + gcm, err := gcmFor(shared) |
| + if err != nil { |
| + return nil, err |
| + } |
| + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) |
| + if err != nil { |
| + return nil, fmt.Errorf("envelope: decryption failed: %w", err) |
| + } |
| + return plaintext, nil |
| +} |
| + |
| +// gcmFor derives the AES-256-GCM cipher an ECDH shared secret drives. |
| +func gcmFor(shared []byte) (cipher.AEAD, error) { |
| + r := hkdf.New(sha256.New, shared, nil, []byte(hkdfInfo)) |
| + key := make([]byte, 32) |
| + if _, err := io.ReadFull(r, key); err != nil { |
| + return nil, err |
| + } |
| + block, err := aes.NewCipher(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return cipher.NewGCM(block) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/appconfig/... -run 'TestSealOpen|TestOpenRejects' -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/crypto.go internal/appconfig/crypto_test.go |
| +git commit -m "appconfig: ECDH-P256/HKDF/AES-GCM seal primitive for secrets" |
| +``` |
| + |
| +--- |
| + |
| +## Task 3: CAS-guarded storage — Get/SetEnv/SetSecret/Unset |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/store.go` |
| +- Test: `internal/appconfig/store_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Manifest`/`Var`/`Key`/`ValidVarName` from Task 1; `release.Store`, `release.ErrCASConflict`, `release.ValidAccount`, `release.ValidApp` from `internal/release` (existing). |
| +- Produces: `func Get(ctx, s release.Store, acct, app string) (*Manifest, error)`, `func SetEnv(ctx, s release.Store, acct, app string, kv map[string]string) error`, `func SetSecret(ctx, s release.Store, acct, app string, sealed map[string]string) error`, `func Unset(ctx, s release.Store, acct, app string, keys []string) error` — Tasks 5, 6, 7 all call these. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/appconfig/store_test.go |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestSetEnvThenGet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + m, err := Get(ctx, s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if v := m.Vars["LOG_LEVEL"]; v.Kind != "env" || v.Value != "debug" { |
| + t.Fatalf("LOG_LEVEL = %+v, want kind env value debug", v) |
| + } |
| +} |
| + |
| +func TestGetOnMissingManifestReturnsEmpty(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + m, err := Get(context.Background(), s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if len(m.Vars) != 0 { |
| + t.Fatalf("Vars = %v, want empty for a never-configured app", m.Vars) |
| + } |
| +} |
| + |
| +func TestUnsetRemovesVar(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil { |
| + t.Fatalf("Unset: %v", err) |
| + } |
| + m, err := Get(ctx, s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if _, ok := m.Vars["LOG_LEVEL"]; ok { |
| + t.Fatalf("LOG_LEVEL still present after Unset") |
| + } |
| +} |
| + |
| +func TestSetSecretStoresEnvelopeOnly(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetSecret(ctx, s, "bab", "keymail", map[string]string{"SES_SECRET": "opaque-envelope-bytes"}); err != nil { |
| + t.Fatalf("SetSecret: %v", err) |
| + } |
| + m, err := Get(ctx, s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + v := m.Vars["SES_SECRET"] |
| + if v.Kind != "secret" || v.Envelope != "opaque-envelope-bytes" || v.Value != "" { |
| + t.Fatalf("SES_SECRET = %+v, want kind secret, envelope set, value empty", v) |
| + } |
| +} |
| + |
| +func TestSetRejectsInvalidVarName(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + err := SetEnv(context.Background(), s, "bab", "keymail", map[string]string{"has-hyphen": "x"}) |
| + if err == nil { |
| + t.Fatal("SetEnv accepted an invalid var name, want an error") |
| + } |
| +} |
| + |
| +// TestConcurrentWriteSurfacesCASConflict proves a write against a stale |
| +// read of the manifest is rejected rather than silently clobbering a |
| +// write that landed in between — the same discipline release.Promote |
| +// already uses for channel pointers. |
| +func TestConcurrentWriteSurfacesCASConflict(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + m, etag, err := getForWrite(ctx, s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("getForWrite: %v", err) |
| + } |
| + // A second operator's write lands first, moving the object on. |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"B": "2"}); err != nil { |
| + t.Fatalf("SetEnv (racing writer): %v", err) |
| + } |
| + // This test's stale etag now loses the race. |
| + m.Vars["C"] = Var{Kind: "env", Value: "3"} |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + if _, err := s.Put(ctx, Key("bab", "keymail"), raw, etag); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("Put with stale etag = %v, want ErrCASConflict", err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/appconfig/... -run 'TestSetEnv|TestGetOnMissing|TestUnset|TestSetSecret|TestSetRejects|TestConcurrentWrite' -v` |
| +Expected: FAIL — `undefined: SetEnv`, `undefined: Get`, `undefined: getForWrite`, etc. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +```go |
| +// internal/appconfig/store.go |
| + |
| +// CAS-guarded reads and writes of a Manifest, on top of release.Store — the |
| +// same read-modify-write-with-If-Match discipline release.Promote already |
| +// uses for channel pointers, without that function's signing (config |
| +// manifests aren't a fleet trust boundary the way a channel pointer is: |
| +// nothing executes because a manifest says so). |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Get fetches app's config manifest, an empty one (not an error) if it has |
| +// never been configured — a valid state for `list` and for Sync (which |
| +// separately decides whether an empty manifest is worth acting on). |
| +func Get(ctx context.Context, s release.Store, acct, app string) (*Manifest, error) { |
| + if !release.ValidAccount(acct) { |
| + return nil, fmt.Errorf("config: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return nil, fmt.Errorf("config: invalid app %q", app) |
| + } |
| + raw, _, err := s.Get(ctx, Key(acct, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil |
| + } |
| + return nil, err |
| + } |
| + var m Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return nil, fmt.Errorf("config %s: %w", Key(acct, app), err) |
| + } |
| + return &m, nil |
| +} |
| + |
| +// getForWrite is Get plus the etag a caller needs to write back — a |
| +// not-found manifest reads as empty with etag "" (create-only Put). |
| +func getForWrite(ctx context.Context, s release.Store, acct, app string) (Manifest, string, error) { |
| + raw, etag, err := s.Get(ctx, Key(acct, app)) |
| + switch { |
| + case err == nil: |
| + var m Manifest |
| + if uerr := json.Unmarshal(raw, &m); uerr != nil { |
| + return Manifest{}, "", fmt.Errorf("config %s: %w", Key(acct, app), uerr) |
| + } |
| + return m, etag, nil |
| + case errors.Is(err, os.ErrNotExist): |
| + return Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", nil |
| + default: |
| + return Manifest{}, "", err |
| + } |
| +} |
| + |
| +// apply reads, applies sets and unsets, and writes back via CAS. A racing |
| +// concurrent write surfaces as release.ErrCASConflict — callers report it |
| +// and let the operator re-run; there is no automatic retry, matching |
| +// release.Promote's own behavior. |
| +func apply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) error { |
| + if !release.ValidAccount(acct) { |
| + return fmt.Errorf("config: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return fmt.Errorf("config: invalid app %q", app) |
| + } |
| + for k := range sets { |
| + if !ValidVarName(k) { |
| + return fmt.Errorf("config: invalid var name %q", k) |
| + } |
| + } |
| + m, etag, err := getForWrite(ctx, s, acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + if m.Vars == nil { |
| + m.Vars = map[string]Var{} |
| + } |
| + for _, k := range unsets { |
| + delete(m.Vars, k) |
| + } |
| + for k, v := range sets { |
| + m.Vars[k] = v |
| + } |
| + m.Schema, m.App = 1, app |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, Key(acct, app), raw, etag) |
| + return err |
| +} |
| + |
| +// SetEnv writes one or more plaintext config vars. |
| +func SetEnv(ctx context.Context, s release.Store, acct, app string, kv map[string]string) error { |
| + sets := make(map[string]Var, len(kv)) |
| + for k, v := range kv { |
| + sets[k] = Var{Kind: "env", Value: v} |
| + } |
| + return apply(ctx, s, acct, app, sets, nil) |
| +} |
| + |
| +// SetSecret writes one or more secrets. sealed values must already be |
| +// Seal-produced envelopes — this function stores them opaquely and never |
| +// sees plaintext. |
| +func SetSecret(ctx context.Context, s release.Store, acct, app string, sealed map[string]string) error { |
| + sets := make(map[string]Var, len(sealed)) |
| + for k, v := range sealed { |
| + sets[k] = Var{Kind: "secret", Envelope: v} |
| + } |
| + return apply(ctx, s, acct, app, sets, nil) |
| +} |
| + |
| +// Unset removes vars (env or secret) by key. Unsetting a key that isn't set |
| +// is a no-op success, matching heroku config:unset's idempotence. |
| +func Unset(ctx context.Context, s release.Store, acct, app string, keys []string) error { |
| + return apply(ctx, s, acct, app, nil, keys) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/appconfig/... -v` |
| +Expected: PASS (all tests from Tasks 1-3) |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/store.go internal/appconfig/store_test.go |
| +git commit -m "appconfig: CAS-guarded manifest storage (Get/SetEnv/SetSecret/Unset)" |
| +``` |
| + |
| +--- |
| + |
| +## Task 4: Render a manifest into an EnvironmentFile |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/materialize.go` |
| +- Test: `internal/appconfig/materialize_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Manifest`/`Var` from Task 1, `Open` from Task 2. |
| +- Produces: `func RenderEnv(m *Manifest, priv *ecdh.PrivateKey) ([]byte, error)` — Task 5's `Sync` calls this. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/appconfig/materialize_test.go |
| +package appconfig |
| + |
| +import "testing" |
| + |
| +func TestRenderEnvMergesPlainAndDecryptedSecrets(t *testing.T) { |
| + priv, pub, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + pubKey, err := ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + envelope, err := Seal(pubKey, []byte("s3cr3t")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{ |
| + "LOG_LEVEL": {Kind: "env", Value: "debug"}, |
| + "SES_SECRET": {Kind: "secret", Envelope: envelope}, |
| + }} |
| + got, err := RenderEnv(m, privKey) |
| + if err != nil { |
| + t.Fatalf("RenderEnv: %v", err) |
| + } |
| + // Sorted by key, so the diff Sync computes against what's on disk is |
| + // deterministic — an unrelated key added or removed elsewhere in the |
| + // map must not reorder every line and force a spurious restart. |
| + want := "LOG_LEVEL=debug\nSES_SECRET=s3cr3t\n" |
| + if string(got) != want { |
| + t.Fatalf("RenderEnv = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestRenderEnvRejectsNewlineInValue(t *testing.T) { |
| + m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{ |
| + "BAD": {Kind: "env", Value: "line1\nline2"}, |
| + }} |
| + if _, err := RenderEnv(m, nil); err == nil { |
| + t.Fatal("RenderEnv accepted a newline in a value, want an error") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/appconfig/... -run TestRenderEnv -v` |
| +Expected: FAIL — `undefined: RenderEnv` |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +```go |
| +// internal/appconfig/materialize.go |
| + |
| +// Rendering a Manifest into the KEY=value lines carlos-app@.service's |
| +// EnvironmentFile directive reads. |
| +package appconfig |
| + |
| +import ( |
| + "crypto/ecdh" |
| + "fmt" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// RenderEnv decrypts m's secret entries with priv and renders the whole |
| +// manifest as EnvironmentFile-shaped `KEY=value\n` lines, keys sorted so |
| +// the output is deterministic and diffable against what's already on |
| +// disk. priv may be nil iff m has no "secret" entries (callers that know |
| +// they're only rendering plain env vars, e.g. a future env-only path, |
| +// don't need a key in hand). |
| +func RenderEnv(m *Manifest, priv *ecdh.PrivateKey) ([]byte, error) { |
| + keys := make([]string, 0, len(m.Vars)) |
| + for k := range m.Vars { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + var b strings.Builder |
| + for _, k := range keys { |
| + v := m.Vars[k] |
| + var val string |
| + switch v.Kind { |
| + case "env": |
| + val = v.Value |
| + case "secret": |
| + if priv == nil { |
| + return nil, fmt.Errorf("var %s: secret entry but no decryption key was supplied", k) |
| + } |
| + plain, err := Open(priv, v.Envelope) |
| + if err != nil { |
| + return nil, fmt.Errorf("decrypt %s: %w", k, err) |
| + } |
| + val = string(plain) |
| + default: |
| + return nil, fmt.Errorf("var %s: unknown kind %q", k, v.Kind) |
| + } |
| + if strings.ContainsAny(val, "\n\x00") { |
| + return nil, fmt.Errorf("var %s: value contains a newline or NUL, not representable in an EnvironmentFile", k) |
| + } |
| + fmt.Fprintf(&b, "%s=%s\n", k, val) |
| + } |
| + return []byte(b.String()), nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/appconfig/... -v` |
| +Expected: PASS (all tests so far) |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/materialize.go internal/appconfig/materialize_test.go |
| +git commit -m "appconfig: render a manifest into an EnvironmentFile" |
| +``` |
| + |
| +--- |
| + |
| +## Task 5: Sync — the adopt-pass-shaped host convergence sweep |
| + |
| +**Files:** |
| +- Create: `internal/appconfig/sync.go` |
| +- Test: `internal/appconfig/sync_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Get` from Task 3, `RenderEnv` from Task 4, `release.Store`/`release.RouteAccount` from `internal/release` (existing), `registry.Registry`/`registry.Route` from `internal/registry` (existing). |
| +- Produces: `const DefaultEnvDir = "/etc/carlos/apps"`, `func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error` — Task 8 wires this into `RunAdopt`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/appconfig/sync_test.go |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func testRegistry(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + return reg |
| +} |
| + |
| +func TestSyncWritesFileAndRestartsOnChange(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + |
| + priv, pub, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + pubKey, err := ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + envelope, err := Seal(pubKey, []byte("shh")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := SetSecret(ctx, s, "bab", "keymail", map[string]string{"SES_SECRET": envelope}); err != nil { |
| + t.Fatalf("SetSecret: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "keymail.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + |
| + var restarted []string |
| + restart := func(host string) error { restarted = append(restarted, host); return nil } |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + |
| + got, err := os.ReadFile(filepath.Join(envDir, "keymail.example.com.env")) |
| + if err != nil { |
| + t.Fatalf("read rendered env: %v", err) |
| + } |
| + want := "LOG_LEVEL=debug\nSES_SECRET=shh\n" |
| + if string(got) != want { |
| + t.Fatalf("rendered env = %q, want %q", got, want) |
| + } |
| + if len(restarted) != 1 || restarted[0] != "keymail.example.com" { |
| + t.Fatalf("restarted = %v, want exactly [keymail.example.com]", restarted) |
| + } |
| + |
| + // A second pass with nothing changed must not write or restart again. |
| + restarted = nil |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + t.Fatalf("Sync (second pass): %v", err) |
| + } |
| + if len(restarted) != 0 { |
| + t.Fatalf("second pass restarted %v, want none", restarted) |
| + } |
| +} |
| + |
| +func TestSyncSkipsRoutesWithNoConfigEverSet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "bare.example.com", Kind: "instance", App: "bareapp", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + var restarted []string |
| + restart := func(host string) error { restarted = append(restarted, host); return nil } |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(envDir, "bare.example.com.env")); !os.IsNotExist(err) { |
| + t.Fatalf("expected no env file for a route with no config ever set, stat err = %v", err) |
| + } |
| + if len(restarted) != 0 { |
| + t.Fatalf("restarted = %v, want none", restarted) |
| + } |
| +} |
| + |
| +func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) { |
| + // One app, two simultaneous routes (e.g. a stable route and a canary |
| + // route are two different Hosts) — the shared config must land in |
| + // both routes' env files, per spec §5's per-route-host materialization. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + for _, host := range []string{"stable.keymail.example.com", "canary.keymail.example.com"} { |
| + if err := reg.Upsert(registry.Route{ |
| + Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("Upsert %s: %v", host, err) |
| + } |
| + } |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + for _, host := range []string{"stable.keymail.example.com", "canary.keymail.example.com"} { |
| + got, err := os.ReadFile(filepath.Join(envDir, host+".env")) |
| + if err != nil { |
| + t.Fatalf("%s: read rendered env: %v", host, err) |
| + } |
| + if string(got) != "LOG_LEVEL=debug\n" { |
| + t.Fatalf("%s: rendered env = %q, want LOG_LEVEL=debug", host, got) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/appconfig/... -run TestSync -v` |
| +Expected: FAIL — `undefined: Sync` |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +```go |
| +// internal/appconfig/sync.go |
| + |
| +// Sync is the host-side convergence sweep: for every app the box's |
| +// registry runs, fetch its config manifest once and materialize the |
| +// result into every route's EnvironmentFile. Shaped after release.Pass |
| +// (resolve once per group, converge once per route) but with none of that |
| +// function's staging/symlink/bake machinery — config isn't versioned. |
| +package appconfig |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdh" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// DefaultEnvDir is where carlos-app@.service's EnvironmentFile directive |
| +// looks for a route's env file in production |
| +// (EnvironmentFile=-/etc/carlos/apps/%i.env, %i = route Host). |
| +const DefaultEnvDir = "/etc/carlos/apps" |
| + |
| +// Sync fetches apps/<acct>/<app>/config.json once per distinct |
| +// (Account, App) among reg's routes, then for every route running that |
| +// app renders <envDir>/<route.Host>.env and, if it changed, writes it and |
| +// restarts that route's unit via restart. Die-free by design, like |
| +// release.Pass: a fetch or render failure for one app logs and continues |
| +// rather than aborting the whole sweep. |
| +func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + type groupKey struct{ acct, app string } |
| + manifests := map[groupKey]*Manifest{} |
| + for _, rt := range routes { |
| + if !syncable(rt) { |
| + continue |
| + } |
| + k := groupKey{release.RouteAccount(rt), rt.App} |
| + if _, done := manifests[k]; done { |
| + continue |
| + } |
| + m, err := Get(ctx, s, k.acct, k.app) |
| + if err != nil { |
| + fmt.Printf(" %s/%s: config fetch failed: %v\n", k.acct, k.app, err) |
| + manifests[k] = nil |
| + continue |
| + } |
| + manifests[k] = m |
| + } |
| + for _, rt := range routes { |
| + if !syncable(rt) { |
| + continue |
| + } |
| + m := manifests[groupKey{release.RouteAccount(rt), rt.App}] |
| + if m == nil { |
| + continue |
| + } |
| + syncHost(rt, m, priv, envDir, restart) |
| + } |
| + return nil |
| +} |
| + |
| +// syncable mirrors release.adoptable: lifecycle-managed instances of a |
| +// known app, excluding platform-minted alias routes (an alias shares its |
| +// canonical route's process and therefore its canonical route's config — |
| +// see release.adoptable's own doc comment for why aliases are excluded |
| +// from adoption the same way). |
| +func syncable(rt registry.Route) bool { |
| + return rt.Kind == "instance" && rt.App != "" && !rt.Platform && |
| + (rt.Status == "" || rt.Status == "ready") |
| +} |
| + |
| +// syncHost converges one route's env file. len(m.Vars)==0 with no existing |
| +// file means the app has never had config set — nothing to converge, and |
| +// writing an empty file would trigger a pointless first restart for every |
| +// app that never touches carlos env/secrets at all. |
| +func syncHost(rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) { |
| + path := filepath.Join(envDir, rt.Host+".env") |
| + cur, err := os.ReadFile(path) |
| + existed := err == nil |
| + if err != nil && !os.IsNotExist(err) { |
| + fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + if len(m.Vars) == 0 && !existed { |
| + return |
| + } |
| + rendered, err := RenderEnv(m, priv) |
| + if err != nil { |
| + fmt.Printf(" %s: render config: %v\n", rt.Host, err) |
| + return |
| + } |
| + if existed && bytes.Equal(cur, rendered) { |
| + return // up to date — stay quiet on the periodic tick |
| + } |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, rendered, 0o600); err != nil { |
| + fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + if err := restart(rt.Host); err != nil { |
| + fmt.Printf(" %s: config updated but restart failed: %v\n", rt.Host, err) |
| + return |
| + } |
| + fmt.Printf(" %s: config updated, restarted\n", rt.Host) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test ./internal/appconfig/... -v` |
| +Expected: PASS (all tests so far) |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/appconfig/sync.go internal/appconfig/sync_test.go |
| +git commit -m "appconfig: Sync — adopt-pass-shaped per-route env materialization" |
| +``` |
| + |
| +--- |
| + |
| +## Task 6: CLI — `carlos env` (set/unset/list) |
| + |
| +**Files:** |
| +- Create: `appconfig_cli.go` |
| +- Modify: `main.go` (add the `env` case and usage line) |
| +- Test: `e2e_test.go` (append `TestEnvCLI`) |
| + |
| +**Interfaces:** |
| +- Consumes: `appconfig.SetEnv`/`Unset`/`Get` from Task 3; `openStore()`, `accountFlags(fs)` from `cli.go` (existing, unexported but same package `main`). |
| +- Produces: `func RunEnv(args []string)`, `func parseKV(args []string) (map[string]string, error)` — Task 7 reuses `parseKV`; `main.go`'s switch gains `case "env": RunEnv(args)`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// Append to e2e_test.go (package main_test — regexp, release, os/exec, |
| +// path/filepath, strings, context are already imported there). |
| + |
| +func TestEnvCLI(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + carlosBin := filepath.Join(tmp, "carlos") |
| + build := exec.Command(goBin, "build", "-o", carlosBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build carlos: %v\n%s", err, out) |
| + } |
| + storeDir := filepath.Join(tmp, "store") |
| + regPath := filepath.Join(tmp, "registry.db") |
| + env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| + run := func(args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(carlosBin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| + } |
| + return string(out) |
| + } |
| + |
| + run("env", "set", "-registry", regPath, "-account", "bab", "-app", "keymail", "LOG_LEVEL=debug", "FEATURE_X=on") |
| + out := run("env", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| + for _, want := range []string{`(?m)^LOG_LEVEL\s+debug$`, `(?m)^FEATURE_X\s+on$`} { |
| + if !regexp.MustCompile(want).MatchString(out) { |
| + t.Fatalf("env list = %q, want a line matching %s", out, want) |
| + } |
| + } |
| + |
| + run("env", "unset", "-registry", regPath, "-account", "bab", "-app", "keymail", "FEATURE_X") |
| + out = run("env", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| + if strings.Contains(out, "FEATURE_X") { |
| + t.Fatalf("env list = %q, still shows unset FEATURE_X", out) |
| + } |
| + if !regexp.MustCompile(`(?m)^LOG_LEVEL\s+debug$`).MatchString(out) { |
| + t.Fatalf("env list = %q, lost LOG_LEVEL after an unrelated unset", out) |
| + } |
| + |
| + // The manifest lands at the account-scoped key, matching every other |
| + // app key internal/release writes. |
| + store := release.NewDirStore(storeDir) |
| + if _, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json"); err != nil { |
| + t.Fatalf("config manifest not at the account-scoped key: %v", err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test . -run TestEnvCLI -v` |
| +Expected: FAIL to build/run — `carlos env` is not a recognized command (falls into `usage(); os.Exit(2)`, so the CLI test's `run()` helper fatals on the non-zero exit). |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +```go |
| +// appconfig_cli.go |
| + |
| +// The `carlos env` and `carlos secrets` verbs: per-app config, backed by |
| +// internal/appconfig. Dispatch shape mirrors RunAccounts/RunAuth (accounts.go, |
| +// auth.go) — a flat switch on the first arg. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "sort" |
| + "strings" |
| + "text/tabwriter" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +const envUsage = `usage: carlos env <command> |
| + |
| +commands: |
| + set -app <a> KEY=value [KEY2=value2 ...] set one or more plain config vars |
| + unset -app <a> KEY [KEY2 ...] remove config vars |
| + list -app <a> list an app's config vars |
| +` |
| + |
| +// RunEnv is the `carlos env` verb. |
| +func RunEnv(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, envUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "set": |
| + runEnvSet(args[1:]) |
| + case "unset": |
| + runEnvUnset(args[1:]) |
| + case "list": |
| + runEnvList(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, envUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// parseKV turns ["KEY=value", "KEY2=value2"] into a map, refusing anything |
| +// without an "=" or with an empty key. Shared by `env set` and `secrets set`. |
| +func parseKV(args []string) (map[string]string, error) { |
| + if len(args) == 0 { |
| + return nil, fmt.Errorf("at least one KEY=value pair is required") |
| + } |
| + out := map[string]string{} |
| + for _, a := range args { |
| + k, v, ok := strings.Cut(a, "=") |
| + if !ok || k == "" { |
| + return nil, fmt.Errorf("invalid KEY=value pair %q", a) |
| + } |
| + out[k] = v |
| + } |
| + return out, nil |
| +} |
| + |
| +func runEnvSet(args []string) { |
| + fs := flag.NewFlagSet("env set", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos env set -app <a> KEY=value [KEY2=value2 ...]") |
| + } |
| + kv, err := parseKV(fs.Args()) |
| + if err != nil { |
| + log.Fatalf("env set: %v", err) |
| + } |
| + if err := appconfig.SetEnv(context.Background(), openStore(), acctOf(), *app, kv); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("set %d env var(s) for %s\n", len(kv), *app) |
| +} |
| + |
| +func runEnvUnset(args []string) { |
| + fs := flag.NewFlagSet("env unset", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() == 0 { |
| + log.Fatal("usage: carlos env unset -app <a> KEY [KEY2 ...]") |
| + } |
| + if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("unset %d var(s) for %s\n", fs.NArg(), *app) |
| +} |
| + |
| +func runEnvList(args []string) { |
| + fs := flag.NewFlagSet("env list", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos env list -app <a>") |
| + } |
| + m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + var keys []string |
| + for k, v := range m.Vars { |
| + if v.Kind == "env" { |
| + keys = append(keys, k) |
| + } |
| + } |
| + sort.Strings(keys) |
| + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, "KEY\tVALUE") |
| + for _, k := range keys { |
| + fmt.Fprintf(w, "%s\t%s\n", k, m.Vars[k].Value) |
| + } |
| + w.Flush() |
| +} |
| +``` |
| + |
| +Wire it into `main.go`: add a case to the switch — no new import needed, `RunEnv` is an unqualified same-package (`main`) call. |
| + |
| +```go |
| +// main.go — add this case to the switch in main(), near "accounts": |
| + case "env": |
| + RunEnv(args) |
| +``` |
| + |
| +And extend the `usage()` string (`main.go`), adding a line near the `ship | promote | channels` entry: |
| + |
| +```go |
| + env set | unset | list — plain per-app config vars |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test . -run TestEnvCLI -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add appconfig_cli.go main.go e2e_test.go |
| +git commit -m "cli: carlos env set/unset/list" |
| +``` |
| + |
| +--- |
| + |
| +## Task 7: CLI — `carlos secrets` (set/unset/list/genkey) |
| + |
| +**Files:** |
| +- Modify: `appconfig_cli.go` (append the secrets dispatcher and its run\* functions) |
| +- Modify: `main.go` (add the `secrets` case and usage line) |
| +- Modify: `e2e_test.go` (append `TestSecretsCLI`; add an `internal/appconfig` import) |
| + |
| +**Interfaces:** |
| +- Consumes: `appconfig.SetSecret`/`Unset`/`Get`/`GenerateKey`/`Seal`/`ParsePrivateKey`/`ParsePublicKey` from Tasks 2/3; `parseKV`, `accountFlags`, `openStore` from Task 6 / `cli.go`. |
| +- Produces: `func RunSecrets(args []string)`; `main.go`'s switch gains `case "secrets": RunSecrets(args)`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// Append to e2e_test.go. Add "github.com/carlosframework/platform/internal/appconfig" |
| +// to the import block (encoding/json, regexp, release, os/exec, strings, |
| +// context are already imported there). |
| + |
| +func TestSecretsCLI(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + carlosBin := filepath.Join(tmp, "carlos") |
| + build := exec.Command(goBin, "build", "-o", carlosBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build carlos: %v\n%s", err, out) |
| + } |
| + storeDir := filepath.Join(tmp, "store") |
| + regPath := filepath.Join(tmp, "registry.db") |
| + baseEnv := os.Environ() |
| + run := func(env []string, args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(carlosBin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| + } |
| + return string(out) |
| + } |
| + storeEnv := append(append([]string{}, baseEnv...), "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| + |
| + keys := run(baseEnv, "secrets", "genkey") |
| + priv := regexp.MustCompile(`(?m)^CARLOS_SECRETS_KEY=(\S+)`).FindStringSubmatch(keys) |
| + pub := regexp.MustCompile(`(?m)^CARLOS_SECRETS_PUBKEY=(\S+)`).FindStringSubmatch(keys) |
| + if priv == nil || pub == nil { |
| + t.Fatalf("secrets genkey printed no key pair:\n%s", keys) |
| + } |
| + |
| + sealEnv := append(append([]string{}, storeEnv...), "CARLOS_SECRETS_PUBKEY="+pub[1]) |
| + run(sealEnv, "secrets", "set", "-registry", regPath, "-account", "bab", "-app", "keymail", "SES_SECRET=s3kr1t") |
| + |
| + out := run(sealEnv, "secrets", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| + if !strings.Contains(out, "SES_SECRET") { |
| + t.Fatalf("secrets list = %q, want SES_SECRET listed", out) |
| + } |
| + if strings.Contains(out, "s3kr1t") { |
| + t.Fatalf("secrets list = %q, printed a secret value — it must only ever print key names", out) |
| + } |
| + |
| + // The value stored in the bucket is opaque ciphertext, not the plaintext. |
| + store := release.NewDirStore(storeDir) |
| + raw, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json") |
| + if err != nil { |
| + t.Fatalf("read config manifest: %v", err) |
| + } |
| + if strings.Contains(string(raw), "s3kr1t") { |
| + t.Fatalf("config.json contains the plaintext secret:\n%s", raw) |
| + } |
| + |
| + // And it decrypts back to the original plaintext with the private half — |
| + // the CLI's Seal and the library's Open agree on the wire format. |
| + var manifest appconfig.Manifest |
| + if err := json.Unmarshal(raw, &manifest); err != nil { |
| + t.Fatalf("unmarshal manifest: %v", err) |
| + } |
| + privKey, err := appconfig.ParsePrivateKey(priv[1], "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + plain, err := appconfig.Open(privKey, manifest.Vars["SES_SECRET"].Envelope) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + if string(plain) != "s3kr1t" { |
| + t.Fatalf("decrypted secret = %q, want s3kr1t", plain) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test . -run TestSecretsCLI -v` |
| +Expected: FAIL to build/run — `carlos secrets` is not a recognized command. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +Append to `appconfig_cli.go`: |
| + |
| +```go |
| +const secretsUsage = `usage: carlos secrets <command> |
| + |
| +commands: |
| + set -app <a> KEY=value [KEY2=value2 ...] seal and set one or more secrets |
| + unset -app <a> KEY [KEY2 ...] remove secrets |
| + list -app <a> list a secret's key names only — never values |
| + genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair |
| +` |
| + |
| +// RunSecrets is the `carlos secrets` verb. |
| +func RunSecrets(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, secretsUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "set": |
| + runSecretsSet(args[1:]) |
| + case "unset": |
| + runSecretsUnset(args[1:]) |
| + case "list": |
| + runSecretsList(args[1:]) |
| + case "genkey": |
| + runSecretsGenkey(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, secretsUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// secretsPubKey reads CARLOS_SECRETS_PUBKEY — distinct from cli.go's |
| +// publicKey()/signingKey(), which are the unrelated release signing pair. |
| +func secretsPubKey() *ecdh.PublicKey { |
| + k, err := appconfig.ParsePublicKey(os.Getenv("CARLOS_SECRETS_PUBKEY"), "CARLOS_SECRETS_PUBKEY") |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + return k |
| +} |
| + |
| +func runSecretsSet(args []string) { |
| + fs := flag.NewFlagSet("secrets set", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos secrets set -app <a> KEY=value [KEY2=value2 ...]") |
| + } |
| + kv, err := parseKV(fs.Args()) |
| + if err != nil { |
| + log.Fatalf("secrets set: %v", err) |
| + } |
| + pub := secretsPubKey() |
| + sealed := make(map[string]string, len(kv)) |
| + for k, v := range kv { |
| + env, err := appconfig.Seal(pub, []byte(v)) |
| + if err != nil { |
| + log.Fatalf("seal %s: %v", k, err) |
| + } |
| + sealed[k] = env |
| + } |
| + if err := appconfig.SetSecret(context.Background(), openStore(), acctOf(), *app, sealed); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("set %d secret(s) for %s\n", len(kv), *app) |
| +} |
| + |
| +func runSecretsUnset(args []string) { |
| + fs := flag.NewFlagSet("secrets unset", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" || fs.NArg() == 0 { |
| + log.Fatal("usage: carlos secrets unset -app <a> KEY [KEY2 ...]") |
| + } |
| + if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("unset %d secret(s) for %s\n", fs.NArg(), *app) |
| +} |
| + |
| +func runSecretsList(args []string) { |
| + fs := flag.NewFlagSet("secrets list", flag.ExitOnError) |
| + app := fs.String("app", "", "app name") |
| + acctOf := accountFlags(fs) |
| + fs.Parse(args) |
| + if *app == "" { |
| + log.Fatal("usage: carlos secrets list -app <a>") |
| + } |
| + m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + var keys []string |
| + for k, v := range m.Vars { |
| + if v.Kind == "secret" { |
| + keys = append(keys, k) |
| + } |
| + } |
| + sort.Strings(keys) |
| + for _, k := range keys { |
| + fmt.Println(k) |
| + } |
| +} |
| + |
| +func runSecretsGenkey(args []string) { |
| + priv, pub, err := appconfig.GenerateKey() |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("CARLOS_SECRETS_KEY=%s\nCARLOS_SECRETS_PUBKEY=%s\n", priv, pub) |
| + fmt.Fprintln(os.Stderr, "store the KEY in your secret manager and in every host's host.env; "+ |
| + "the PUBKEY can be held widely — it only encrypts") |
| +} |
| +``` |
| + |
| +Add `"crypto/ecdh"` to `appconfig_cli.go`'s import block (needed for `secretsPubKey`'s return type). |
| + |
| +Wire it into `main.go`: |
| + |
| +```go |
| +// main.go — add this case to the switch, next to "env": |
| + case "secrets": |
| + RunSecrets(args) |
| +``` |
| + |
| +And extend `usage()`: |
| + |
| +```go |
| + secrets set | unset | list | genkey — sealed per-app secrets |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `go test . -run 'TestEnvCLI|TestSecretsCLI' -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add appconfig_cli.go main.go e2e_test.go |
| +git commit -m "cli: carlos secrets set/unset/list/genkey" |
| +``` |
| + |
| +--- |
| + |
| +## Task 8: Wire `appconfig.Sync` into the root `carlos adopt` path |
| + |
| +**Files:** |
| +- Modify: `internal/host/agent.go` (`RunAdopt`, and its import block) |
| + |
| +**Interfaces:** |
| +- Consumes: `appconfig.Sync`, `appconfig.ParsePrivateKey`, `appconfig.DefaultEnvDir` from Task 5/2; `restartRoute`, `openDeployStore`, `release.Pass` already used by `RunAdopt` (existing). |
| +- Produces: nothing new for later tasks — this is the final integration point. |
| + |
| +**Why this task has no new test:** `RunAdopt` (like `adoptLoop`/`statusLoop`/`domainsLoop`) is thin by convention — "everything with behavior lives in" the package function it calls (see `domainsLoop`'s doc comment, `internal/host/agent.go:358`). `appconfig.Sync` is already fully tested in Task 5; this task only wires it in. Verification is `go build ./...` and `go vet ./...` compiling clean, plus the manual smoke check in Step 3. |
| + |
| +- [ ] **Step 1: Manually verify the current behavior (no automated test to write first — see above)** |
| + |
| +Run: `go test ./internal/host/... -v` to confirm the existing suite is green before touching `RunAdopt`. |
| + |
| +- [ ] **Step 2: Make the change** |
| + |
| +```go |
| +// internal/host/agent.go — add to the import block: |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +``` |
| + |
| +```go |
| +// internal/host/agent.go — replace RunAdopt's body: |
| + |
| +// RunAdopt is the `carlos adopt` verb: one immediate adoption pass — the |
| +// carlos-adopt.timer unit's ExecStart, and an operator's "converge now". |
| +// Runs as root (see carlos-adopt.service): this is the ONLY place config |
| +// sync may run, because it writes /etc/carlos/apps and restarts units — |
| +// the same filesystem-write shape release staging needs root for, and |
| +// exactly what the sandboxed carlos-edge.service's in-process adopt loop |
| +// (ProtectSystem=strict, ReadWritePaths=/data /run/carlos only) cannot do. |
| +func RunAdopt(args []string) { |
| + fs := flag.NewFlagSet("adopt", flag.ExitOnError) |
| + regPath := fs.String("registry", filepath.Join(dataDir(), "registry.db"), "registry database") |
| + _ = fs.Parse(args) |
| + reg, err := registry.Open(*regPath) |
| + if err != nil { |
| + log.Fatalf("adopt: open registry %s: %v", *regPath, err) |
| + } |
| + ctx := context.Background() |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Fatalf("adopt: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + log.Fatalf("adopt: %v", err) |
| + } |
| + if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute); err != nil { |
| + log.Fatalf("adopt: %v", err) |
| + } |
| + |
| + // Config sync is optional — inert until CARLOS_SECRETS_KEY is set, the |
| + // same convention every other optional loop in this package follows. |
| + if raw := os.Getenv("CARLOS_SECRETS_KEY"); raw != "" { |
| + secretsPriv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| + if err != nil { |
| + log.Printf("adopt: config sync disabled: %v", err) |
| + } else if err := appconfig.Sync(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restartRoute); err != nil { |
| + log.Printf("adopt: config sync: %v", err) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Verify it compiles and the existing suite still passes** |
| + |
| +Run: `go build ./... && go vet ./... && go test ./internal/host/... -v` |
| +Expected: builds clean, `go vet` clean, all `internal/host` tests still PASS (`RunAdopt` has no dedicated test today, per the "untested by convention" note above — this step just proves the change didn't break anything that IS tested, e.g. `TestPassRepointsAwakeAndHibernating`-style coverage that lives in `internal/release`). |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add internal/host/agent.go |
| +git commit -m "host: wire appconfig.Sync into the root carlos adopt path" |
| +``` |
| + |
| +--- |
| + |
| +## Final full-suite check |
| + |
| +- [ ] Run the complete suite once every task above is done: `go build ./... && go vet ./... && go test ./... -v` |
| +- [ ] Confirm `carlos env`/`carlos secrets` both appear in `carlos` with no arguments (the `usage()` output). |
diff --git a/docs/superpowers/specs/2026-08-02-app-config-secrets-design.md b/docs/superpowers/specs/2026-08-02-app-config-secrets-design.md| new file mode 100644 |
| index 000000000..f8596abef |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-02-app-config-secrets-design.md |
| @@ -0,0 +1,225 @@ |
| +# App config and secrets — `carlos env` / `carlos secrets` |
| + |
| +Apps deployed on the platform get runtime config today by an operator |
| +hand-staging a plaintext file, `/etc/carlos/apps/<app>.env`, directly onto |
| +each host (`carlos-app@.service` already reads it via |
| +`EnvironmentFile=-/etc/carlos/apps/%i.env`). There is no CLI for it, no |
| +bucket-backed record of what an app's config even is, and secret values sit |
| +on disk protected only by file permissions. This spec gives operators a |
| +`carlos env`/`carlos secrets` CLI, backed by the deployment bucket — the |
| +same "no control-plane server, bucket is source of truth" model `ship`/ |
| +`promote` already use — with secret values encrypted at rest and in |
| +transit. |
| + |
| +## 1. Scope |
| + |
| +**App-level only.** This covers per-app runtime config and secrets — the |
| +`heroku config:set` analog. It does **not** cover host/platform bootstrap |
| +config (`host.env`, `CARLOS_RELEASE_KEY`, the deployment bucket |
| +credentials): that stays exactly as it works today, manually staged by |
| +whoever provisions a host. The two are different trust boundaries — bucket |
| +content is fleet-wide and self-service per app; `host.env` is host |
| +bring-up, done once per box by someone with SSH/SSM access. |
| + |
| +One set of values per app, shared across every channel it runs (canary, |
| +stable, whatever else). Per-channel overrides (a canary pointed at a |
| +staging DB while stable points at prod) are a real future need but not a |
| +v1 requirement — nothing here forecloses adding a per-channel layer later, |
| +it's just not designed in now. |
| + |
| +## 2. CLI |
| + |
| +``` |
| +carlos env set -app <a> KEY=value [KEY2=value2 ...] |
| +carlos env unset -app <a> KEY |
| +carlos env list -app <a> |
| + |
| +carlos secrets set -app <a> KEY=value [KEY2=value2 ...] |
| +carlos secrets unset -app <a> KEY |
| +carlos secrets list -app <a> |
| +carlos secrets genkey |
| +``` |
| + |
| +`env` values are plain config — not sensitive, always shown in full by |
| +`list`. `secrets` values are sealed client-side before upload; `secrets |
| +list` prints key names only, values are never re-displayed by the CLI once |
| +set (the same convention Heroku/Fly config-var tooling uses — if you need |
| +the value again, read it from wherever you got it, not from carlos). |
| + |
| +`carlos secrets genkey` mints the new `CARLOS_SECRETS_KEY` / |
| +`CARLOS_SECRETS_PUBKEY` pair (see §4). This is a **new, separate verb** |
| +from the existing `carlos genkey`, which keeps minting the unrelated |
| +release *signing* key exactly as it does today — overloading that verb for |
| +a differently-shaped key (encryption, not signing) would make its output |
| +ambiguous. |
| + |
| +## 3. Storage |
| + |
| +One manifest per app, `apps/<acct>/<app>/config.json` in the deployment |
| +bucket — account-scoped like every other app key `internal/release` |
| +already writes (`AppPrefix(acct, app)`; app names are unique only within |
| +an account, not globally) — holding both env and secret entries together |
| +so materialization is a single fetch: |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "app": "keymail", |
| + "vars": { |
| + "LOG_LEVEL": {"kind": "env", "value": "debug"}, |
| + "SES_SECRET": {"kind": "secret", "envelope": "<base64 ephPub‖iv‖ciphertext>"} |
| + } |
| +} |
| +``` |
| + |
| +Writes are read-modify-write through the same `Store` abstraction and |
| +If-Match/CAS pattern `internal/release.Promote` already uses for channel |
| +pointers: a `set`/`unset` reads the current manifest, applies the change, |
| +and writes back conditioned on the object being unchanged since the read. |
| +A racing concurrent edit surfaces as `ErrCASConflict` (already defined in |
| +`internal/release`) and the CLI reports it and exits non-zero rather than |
| +silently clobbering the other write. This lives in a new `internal/ |
| +appconfig` package, parallel to `internal/release`, not inside it — config |
| +manifests and release manifests are different objects with different |
| +shapes, sharing only the underlying `Store`. |
| + |
| +## 4. Crypto |
| + |
| +A new keypair, distinct from `CARLOS_RELEASE_KEY`/`CARLOS_RELEASE_PUBKEY`: |
| + |
| +- `CARLOS_SECRETS_KEY` — private, **decrypts**. Must be present in every |
| + host's `host.env` in the deployment (the adopt pass needs it to |
| + materialize `.env` files). This is the sensitive half. |
| +- `CARLOS_SECRETS_PUBKEY` — public, **encrypts**. Safe to hold widely — |
| + even checked into a repo — since it grants only the ability to add new |
| + secrets, not to read existing ones. |
| + |
| +This is the mirror image of the release signing key's sensitivity split |
| +(there, the *private* half stays off every host and only the *public* |
| +half is fleet-wide; here, the *private* half has to be on every host and |
| +the *public* half is the safe-to-share one) — worth calling out explicitly |
| +since it's easy to reach for the same shape as the release key and get the |
| +sensitivity backwards. |
| + |
| +The envelope format mirrors the framework's planned crypto core (`Seal`/ |
| +`Open`: ECDH-P256 ephemeral → HKDF-SHA256 → AES-GCM, wire shape |
| +`ephPub(65) ‖ iv(12) ‖ ciphertext`), but is **reimplemented as a small |
| +internal primitive inside `platform`**, not imported from the `rastrillo` |
| +repo. Platform is the substrate the framework is built on top of; a |
| +platform → framework import would run the dependency backwards. Given the |
| +primitive is fully specified already (§6 of the framework design doc), this |
| +is copying a well-defined ~100-line shape, not inventing new crypto. |
| + |
| +`carlos env` values are never encrypted — plaintext in the manifest and in |
| +`list` output, matching that they're config, not secrets. |
| + |
| +## 5. Distribution and materialization |
| + |
| +No new poller — but specifically the **root-run** one. The platform |
| +already runs release adoption through two paths: an in-process loop inside |
| +`carlos agent` (`carlos-edge.service`), and a root `carlos adopt` one-shot |
| +fired hourly by `carlos-adopt.timer`. They are not redundant by accident — |
| +`carlos-edge.service` runs as an unprivileged user under |
| +`ProtectSystem=strict` with `ReadWritePaths=/data /run/carlos` only, so it |
| +cannot write `/opt/carlos` (the version store) or `/etc/carlos/apps` |
| +(per-route env files) at all; `carlos-adopt.service` runs as root |
| +specifically because a pass needs to. Config sync has the exact same |
| +filesystem-write shape as release staging, so it belongs on the same side |
| +of that split: **the root `carlos adopt` path only**, never the in-process |
| +loop inside the sandboxed edge unit (adding it there would just log |
| +permission errors hourly for no effect). Config isn't versioned or |
| +channeled, so it doesn't need the ~2s canary-speed path either — the |
| +hourly root cadence is enough. |
| + |
| +Each `carlos adopt` pass resolves every distinct `(Account, App)` among the |
| +box's routes once, fetches `apps/<acct>/<app>/config.json`, and decrypts |
| +its `kind:"secret"` entries with `CARLOS_SECRETS_KEY` (sourced from |
| +`host.env`, alongside `CARLOS_RELEASE_PUBKEY` — both are read by |
| +`carlos-adopt.service`'s existing `EnvironmentFile=-/etc/carlos/host.env`, |
| +no unit change needed). A box with `CARLOS_SECRETS_KEY` unset simply skips |
| +config sync — inert until configured, the same convention every other |
| +optional loop in this codebase already follows. |
| + |
| +**Important: the env file the systemd unit reads is keyed by *route host*, |
| +not by app.** `carlos-app@.service`'s `EnvironmentFile=-/etc/carlos/apps/ |
| +%i.env` — `%i` is a route's `Host`, the same value that names its |
| +`live/<host>` symlink. Since one app can run several simultaneous routes |
| +on a box (e.g. a `stable` route and a `canary/*` route are two different |
| +hosts, per `release.Pass`'s existing per-route convergence), the *same* |
| +decrypted config is materialized once per route: for every route on the |
| +box whose `App` matches, the adopt pass computes `/etc/carlos/apps/ |
| +<route.Host>.env`'s content and, if it differs from what's on disk, |
| +rewrites it (root-owned, 0600, same as today's hand-staged file) and |
| +restarts that route's `carlos-app@<route.Host>.service` — the same |
| +restart path already used when a new release is adopted for that route. |
| +Unchanged routes get neither a write nor a restart. No change to the |
| +systemd unit itself; `EnvironmentFile=-/etc/carlos/apps/%i.env` already |
| +exists and already does the right thing once the file is real. |
| + |
| +## 6. Non-goals |
| + |
| +- Per-channel config/secret overrides (§1). |
| +- Host/platform bootstrap secrets — `host.env` stays manually staged (§1). |
| +- Any AWS-specific secret backend (SSM Parameter Store, Secrets Manager). |
| + Tying this to an AWS service would cut against the platform's |
| + self-hostable, bucket-is-source-of-truth design. |
| +- Any authorization layer beyond bucket-write IAM. Matches how `ship`/ |
| + `promote` work today — whoever holds bucket-write credentials for a |
| + deployment can set any app's config in it; there's no additional |
| + mechanical access control being added here. |
| + |
| +## 7. Testing |
| + |
| +- Golden envelope test vectors for the seal/open primitive, so the format |
| + can't silently drift once real secrets depend on it (no JS twin exists |
| + on the platform side, unlike the framework crypto core, but the |
| + vector-based test is worth having regardless). |
| +- A CAS-conflict test: two concurrent `set` calls against the same app, |
| + the second must fail with `ErrCASConflict` rather than silently losing |
| + the first write. |
| +- An adopt-pass integration test asserting both halves of §5: a config |
| + change produces the right `.env` file content and a restart; an |
| + unchanged config produces neither. |
| + |
| +## 8. Post-review amendments (2026-08-02) |
| + |
| +Recorded after the whole-branch review, before merge: |
| + |
| +- **Exec-backed routes are out of scope for materialization (for now).** |
| + §5 reasons entirely from `carlos-app@.service`'s `EnvironmentFile`, but |
| + a route with `Backing == ""` (the default, and the Eleven shape most of |
| + the fleet has) runs as an exec child of the agent and reads |
| + `/data/<host>.env` — a file the activator owns and that also carries |
| + `CARLOS_ADMIN_TOKEN`, so it must be *merged into*, never overwritten. |
| + Until that's designed, `Sync` skips exec-backed routes with a log line |
| + rather than writing a file nothing reads. Extending config sync to the |
| + exec backend is explicitly future work. |
| +- **Sync runs before release adoption in the pass**, so a newly adopted |
| + route's first start already sees its env file and a pass carrying both |
| + a release and a config change never starts the new binary against stale |
| + config. |
| +- **An empty route list never reaps.** `registry.Open` silently creates an |
| + empty database when handed a wrong path or an unmounted `/data`, so zero |
| + routes is an easily reachable error state, not authority to delete |
| + anything. |
| +- **Values are validated at set time** (shared predicate with `RenderEnv`): |
| + systemd's EnvironmentFile parser mangles backslashes and quotes, and a |
| + value that only failed at render time would stall the whole app's config |
| + on every host with nothing for the operator to inspect — `secrets list` |
| + deliberately never shows values. |
| +- **A manifest with `schema > 1` is refused, not downgraded** — on read |
| + and on write-back. |
| +- **Key rotation stance (v1):** there is none. Values are write-only by |
| + design, so rotating the keypair means re-entering every secret of every |
| + app from its source of record. Acceptable for v1; a future |
| + `carlos secrets rewrap -old-key` is the obvious escape hatch. |
| +- **Relationship to the pipelines spec:** the same-day pipelines design |
| + (§4 of `2026-08-02-pipelines-design.md`) sketches a second config plane |
| + — `environments/<acct>/<app>/<name>.json` bundles, materialized by the |
| + activator at spawn, with secrets *referenced* rather than inlined. This |
| + spec ships one inlined-(sealed)-set-per-app materialized by the adopt |
| + pass, which is deliberately simpler and exists today. When a pipelines |
| + slice implements environments, `apps/<acct>/<app>/config.json` (schema 1) |
| + is the object it must subsume or layer beneath (e.g. as the default |
| + bundle); that reconciliation is deferred to that slice, and nothing about |
| + this feature's bucket layout or CLI is final beyond schema 1. |
diff --git a/e2e_test.go b/e2e_test.go| index f464c752c..9b81a25e3 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -34,6 +34,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -1305,3 +1306,128 @@ func TestAccountsMigrateLegacyIntoOps(t *testing.T) { |
| t.Fatalf("migrating INTO legacy was accepted:\n%s", out) |
| } |
| } |
| + |
| +func TestEnvCLI(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + carlosBin := filepath.Join(tmp, "carlos") |
| + build := exec.Command(goBin, "build", "-o", carlosBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build carlos: %v\n%s", err, out) |
| + } |
| + storeDir := filepath.Join(tmp, "store") |
| + regPath := filepath.Join(tmp, "registry.db") |
| + env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| + run := func(args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(carlosBin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| + } |
| + return string(out) |
| + } |
| + |
| + run("env", "set", "-registry", regPath, "-account", "bab", "-app", "keymail", "LOG_LEVEL=debug", "FEATURE_X=on") |
| + out := run("env", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| + for _, want := range []string{`(?m)^LOG_LEVEL\s+debug$`, `(?m)^FEATURE_X\s+on$`} { |
| + if !regexp.MustCompile(want).MatchString(out) { |
| + t.Fatalf("env list = %q, want a line matching %s", out, want) |
| + } |
| + } |
| + |
| + run("env", "unset", "-registry", regPath, "-account", "bab", "-app", "keymail", "FEATURE_X") |
| + out = run("env", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| + if strings.Contains(out, "FEATURE_X") { |
| + t.Fatalf("env list = %q, still shows unset FEATURE_X", out) |
| + } |
| + if !regexp.MustCompile(`(?m)^LOG_LEVEL\s+debug$`).MatchString(out) { |
| + t.Fatalf("env list = %q, lost LOG_LEVEL after an unrelated unset", out) |
| + } |
| + |
| + // The manifest lands at the account-scoped key, matching every other |
| + // app key internal/release writes. |
| + store := release.NewDirStore(storeDir) |
| + if _, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json"); err != nil { |
| + t.Fatalf("config manifest not at the account-scoped key: %v", err) |
| + } |
| +} |
| + |
| +func TestSecretsCLI(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + carlosBin := filepath.Join(tmp, "carlos") |
| + build := exec.Command(goBin, "build", "-o", carlosBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build carlos: %v\n%s", err, out) |
| + } |
| + storeDir := filepath.Join(tmp, "store") |
| + regPath := filepath.Join(tmp, "registry.db") |
| + baseEnv := os.Environ() |
| + run := func(env []string, args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(carlosBin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| + } |
| + return string(out) |
| + } |
| + storeEnv := append(append([]string{}, baseEnv...), "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| + |
| + keys := run(baseEnv, "secrets", "genkey") |
| + priv := regexp.MustCompile(`(?m)^CARLOS_SECRETS_KEY=(\S+)`).FindStringSubmatch(keys) |
| + pub := regexp.MustCompile(`(?m)^CARLOS_SECRETS_PUBKEY=(\S+)`).FindStringSubmatch(keys) |
| + if priv == nil || pub == nil { |
| + t.Fatalf("secrets genkey printed no key pair:\n%s", keys) |
| + } |
| + |
| + sealEnv := append(append([]string{}, storeEnv...), "CARLOS_SECRETS_PUBKEY="+pub[1]) |
| + run(sealEnv, "secrets", "set", "-registry", regPath, "-account", "bab", "-app", "keymail", "SES_SECRET=s3kr1t") |
| + |
| + out := run(sealEnv, "secrets", "list", "-registry", regPath, "-account", "bab", "-app", "keymail") |
| + if !strings.Contains(out, "SES_SECRET") { |
| + t.Fatalf("secrets list = %q, want SES_SECRET listed", out) |
| + } |
| + if strings.Contains(out, "s3kr1t") { |
| + t.Fatalf("secrets list = %q, printed a secret value — it must only ever print key names", out) |
| + } |
| + |
| + // The value stored in the bucket is opaque ciphertext, not the plaintext. |
| + store := release.NewDirStore(storeDir) |
| + raw, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json") |
| + if err != nil { |
| + t.Fatalf("read config manifest: %v", err) |
| + } |
| + if strings.Contains(string(raw), "s3kr1t") { |
| + t.Fatalf("config.json contains the plaintext secret:\n%s", raw) |
| + } |
| + |
| + // And it decrypts back to the original plaintext with the private half — |
| + // the CLI's Seal and the library's Open agree on the wire format. |
| + var manifest appconfig.Manifest |
| + if err := json.Unmarshal(raw, &manifest); err != nil { |
| + t.Fatalf("unmarshal manifest: %v", err) |
| + } |
| + privKey, err := appconfig.ParsePrivateKey(priv[1], "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + plain, err := appconfig.Open(privKey, manifest.Vars["SES_SECRET"].Envelope) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + if string(plain) != "s3kr1t" { |
| + t.Fatalf("decrypted secret = %q, want s3kr1t", plain) |
| + } |
| +} |
diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go| new file mode 100644 |
| index 000000000..ca4b4e729 |
| --- /dev/null |
| +++ b/internal/appconfig/appconfig.go |
| @@ -0,0 +1,50 @@ |
| +// Package appconfig is the per-app config/secrets plane: a bucket-backed |
| +// manifest of plain env vars and sealed secrets (internal/release parallel — |
| +// see its doc comment — sharing only the underlying Store), a native |
| +// seal/open primitive, and the host-side render/sync that materializes a |
| +// manifest into the EnvironmentFile carlos-app@.service already reads. |
| +// |
| +// Bucket layout (deployment bucket, account-scoped like every other app key |
| +// internal/release writes): |
| +// |
| +// apps/<acct>/<app>/config.json CAS-guarded config manifest |
| +package appconfig |
| + |
| +import ( |
| + "regexp" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Manifest holds every config var and secret an app has set, together so a |
| +// host materializes both with one bucket fetch. |
| +type Manifest struct { |
| + Schema int `json:"schema"` // 1 |
| + App string `json:"app"` |
| + Vars map[string]Var `json:"vars"` |
| +} |
| + |
| +// Var is one config var or secret. Kind selects which of Value/Envelope is |
| +// populated: "env" vars are plaintext (Value); "secret" vars are sealed |
| +// client-side before upload (Envelope, base64 — see crypto.go's Seal). |
| +type Var struct { |
| + Kind string `json:"kind"` |
| + Value string `json:"value,omitempty"` |
| + Envelope string `json:"envelope,omitempty"` |
| +} |
| + |
| +// Key is the bucket key of app's config manifest, account-scoped by |
| +// deriving it from release.AppPrefix rather than restating its shape (app |
| +// names are unique only within an account) — a manifest has to sit under |
| +// the same prefix as everything else the app owns, permanently. |
| +func Key(acct, app string) string { |
| + return release.AppPrefix(acct, app) + "config.json" |
| +} |
| + |
| +// varNameRe: a leading letter or underscore, then letters/digits/underscore |
| +// — the shape every shell and systemd's EnvironmentFile parser expect a |
| +// variable name to have. Case is not restricted (systemd doesn't care). |
| +var varNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) |
| + |
| +// ValidVarName reports whether k is usable as an env/secret var name. |
| +func ValidVarName(k string) bool { return varNameRe.MatchString(k) } |
diff --git a/internal/appconfig/appconfig_test.go b/internal/appconfig/appconfig_test.go| new file mode 100644 |
| index 000000000..2fd1cbfa6 |
| --- /dev/null |
| +++ b/internal/appconfig/appconfig_test.go |
| @@ -0,0 +1,26 @@ |
| +package appconfig |
| + |
| +import "testing" |
| + |
| +func TestKeyIsAccountScoped(t *testing.T) { |
| + got := Key("bab", "keymail") |
| + want := "apps/bab/keymail/config.json" |
| + if got != want { |
| + t.Fatalf("Key(bab, keymail) = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestValidVarName(t *testing.T) { |
| + valid := []string{"LOG_LEVEL", "_FOO", "A", "SES_SECRET_2", "lower_case"} |
| + invalid := []string{"", "1FOO", "has-hyphen", "has space", "has=equals"} |
| + for _, k := range valid { |
| + if !ValidVarName(k) { |
| + t.Errorf("ValidVarName(%q) = false, want true", k) |
| + } |
| + } |
| + for _, k := range invalid { |
| + if ValidVarName(k) { |
| + t.Errorf("ValidVarName(%q) = true, want false", k) |
| + } |
| + } |
| +} |
diff --git a/internal/appconfig/crypto.go b/internal/appconfig/crypto.go| new file mode 100644 |
| index 000000000..2019e602c |
| --- /dev/null |
| +++ b/internal/appconfig/crypto.go |
| @@ -0,0 +1,159 @@ |
| +// Envelope format and key handling for per-app secrets: ECDH-P256 ephemeral |
| +// -> HKDF-SHA256 -> AES-256-GCM, wire shape ephPub(65) || nonce(12) || |
| +// ciphertext, base64-encoded for storage in a Manifest Var's Envelope |
| +// field. Mirrors the shape the framework's crypto core plans for its own |
| +// Seal/Open (docs/superpowers/specs/2026-08-01-carlos-framework-design.md |
| +// §6), reimplemented natively here since platform cannot import the |
| +// framework repo (platform is the substrate the framework builds on). |
| +// |
| +// Key sensitivity is the MIRROR IMAGE of CARLOS_RELEASE_KEY's: there, the |
| +// private half stays off every host and only the public half is |
| +// fleet-wide; here, the private half (CARLOS_SECRETS_KEY) must reach every |
| +// host that decrypts, and the public half (CARLOS_SECRETS_PUBKEY) is the |
| +// one safe to hold widely — it can only encrypt, never decrypt. |
| +package appconfig |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "strings" |
| + |
| + "golang.org/x/crypto/hkdf" |
| +) |
| + |
| +// randReader is a var so tests can inject determinism for golden vectors. |
| +var randReader io.Reader = rand.Reader |
| + |
| +// hkdfInfo domain-separates this derivation from any other use of the same |
| +// shared secret, the same convention the framework crypto core's Sign |
| +// context strings use for its own purposes. |
| +const hkdfInfo = "carlos-secrets-v1" |
| + |
| +const ( |
| + ephLen = 65 // uncompressed P-256 point |
| + nonceLen = 12 |
| +) |
| + |
| +// GenerateKey mints a fresh P-256 ECDH pair: the base64 private key (the |
| +// operator's secret — must reach every host's host.env as |
| +// CARLOS_SECRETS_KEY) and the base64 public key (CARLOS_SECRETS_PUBKEY, |
| +// safe to hold widely). |
| +func GenerateKey() (privB64, pubB64 string, err error) { |
| + priv, err := ecdh.P256().GenerateKey(randReader) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + return base64.StdEncoding.EncodeToString(priv.Bytes()), |
| + base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), nil |
| +} |
| + |
| +// ParsePrivateKey turns a base64 P-256 scalar — from CARLOS_SECRETS_KEY — |
| +// into the decryption key. src only names the origin in the error. |
| +func ParsePrivateKey(b64, src string) (*ecdh.PrivateKey, error) { |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: invalid base64: %w", src, err) |
| + } |
| + k, err := ecdh.P256().NewPrivateKey(raw) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", src, err) |
| + } |
| + return k, nil |
| +} |
| + |
| +// ParsePublicKey turns a base64 P-256 point — from CARLOS_SECRETS_PUBKEY — |
| +// into the encryption key. |
| +func ParsePublicKey(b64, src string) (*ecdh.PublicKey, error) { |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: invalid base64: %w", src, err) |
| + } |
| + k, err := ecdh.P256().NewPublicKey(raw) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", src, err) |
| + } |
| + return k, nil |
| +} |
| + |
| +// Seal encrypts plaintext for pub with a fresh ephemeral key and nonce. |
| +func Seal(pub *ecdh.PublicKey, plaintext []byte) (string, error) { |
| + eph, err := ecdh.P256().GenerateKey(randReader) |
| + if err != nil { |
| + return "", err |
| + } |
| + nonce := make([]byte, nonceLen) |
| + if _, err := io.ReadFull(randReader, nonce); err != nil { |
| + return "", err |
| + } |
| + return sealWith(eph, nonce, pub, plaintext) |
| +} |
| + |
| +// sealWith is Seal with the ephemeral key and nonce supplied — the seam the |
| +// golden-vector test uses for a deterministic envelope. |
| +func sealWith(eph *ecdh.PrivateKey, nonce []byte, pub *ecdh.PublicKey, plaintext []byte) (string, error) { |
| + shared, err := eph.ECDH(pub) |
| + if err != nil { |
| + return "", err |
| + } |
| + gcm, err := gcmFor(shared) |
| + if err != nil { |
| + return "", err |
| + } |
| + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) |
| + out := make([]byte, 0, ephLen+nonceLen+len(ciphertext)) |
| + out = append(out, eph.PublicKey().Bytes()...) |
| + out = append(out, nonce...) |
| + out = append(out, ciphertext...) |
| + return base64.StdEncoding.EncodeToString(out), nil |
| +} |
| + |
| +// Open decrypts an envelope Seal produced, using priv. |
| +func Open(priv *ecdh.PrivateKey, envelopeB64 string) ([]byte, error) { |
| + raw, err := base64.StdEncoding.DecodeString(envelopeB64) |
| + if err != nil { |
| + return nil, fmt.Errorf("envelope: invalid base64: %w", err) |
| + } |
| + if len(raw) < ephLen+nonceLen { |
| + return nil, errors.New("envelope: too short") |
| + } |
| + ephPub, err := ecdh.P256().NewPublicKey(raw[:ephLen]) |
| + if err != nil { |
| + return nil, fmt.Errorf("envelope: %w", err) |
| + } |
| + nonce := raw[ephLen : ephLen+nonceLen] |
| + ciphertext := raw[ephLen+nonceLen:] |
| + shared, err := priv.ECDH(ephPub) |
| + if err != nil { |
| + return nil, fmt.Errorf("envelope: %w", err) |
| + } |
| + gcm, err := gcmFor(shared) |
| + if err != nil { |
| + return nil, err |
| + } |
| + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) |
| + if err != nil { |
| + return nil, fmt.Errorf("envelope: decryption failed: %w", err) |
| + } |
| + return plaintext, nil |
| +} |
| + |
| +// gcmFor derives the AES-256-GCM cipher an ECDH shared secret drives. |
| +func gcmFor(shared []byte) (cipher.AEAD, error) { |
| + r := hkdf.New(sha256.New, shared, nil, []byte(hkdfInfo)) |
| + key := make([]byte, 32) |
| + if _, err := io.ReadFull(r, key); err != nil { |
| + return nil, err |
| + } |
| + block, err := aes.NewCipher(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return cipher.NewGCM(block) |
| +} |
diff --git a/internal/appconfig/crypto_test.go b/internal/appconfig/crypto_test.go| new file mode 100644 |
| index 000000000..2218392fa |
| --- /dev/null |
| +++ b/internal/appconfig/crypto_test.go |
| @@ -0,0 +1,114 @@ |
| +// internal/appconfig/crypto_test.go |
| +package appconfig |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdh" |
| + "encoding/base64" |
| + "testing" |
| +) |
| + |
| +// fixedScalar returns a 32-byte P-256 scalar for deterministic test keys — |
| +// never used outside tests. Byte 0 is pinned to 0x01 to keep it a valid |
| +// scalar well under the P-256 order regardless of the fill byte. |
| +func fixedScalar(b byte) []byte { |
| + s := make([]byte, 32) |
| + for i := range s { |
| + s[i] = b |
| + } |
| + s[0] = 0x01 |
| + return s |
| +} |
| + |
| +// TestSealOpenGoldenVector pins the wire format (ephPub(65) || nonce(12) || |
| +// ciphertext, base64) against a fixed ephemeral key and nonce so a future |
| +// change to field order, sizes, or the HKDF info string breaks loudly |
| +// instead of silently producing envelopes older code can't read. The |
| +// expected value was computed by running this exact algorithm once, not |
| +// invented — see the plan's Task 2 for how. |
| +func TestSealOpenGoldenVector(t *testing.T) { |
| + recipientPriv, err := ecdh.P256().NewPrivateKey(fixedScalar(0x11)) |
| + if err != nil { |
| + t.Fatalf("recipient key: %v", err) |
| + } |
| + ephPriv, err := ecdh.P256().NewPrivateKey(fixedScalar(0x22)) |
| + if err != nil { |
| + t.Fatalf("ephemeral key: %v", err) |
| + } |
| + nonce := make([]byte, 12) |
| + for i := range nonce { |
| + nonce[i] = byte(i + 1) |
| + } |
| + plaintext := []byte("s3kr1t-value") |
| + |
| + const wantEnvelope = "BKtNPVwwBQiJVQNbG6mW7bJMPFZfpNRwU1bRNeKQcV29Pzvpof1ONhvRxqXEJnk/4+qdPPRg1qWtCl0sgEsTaUgBAgMEBQYHCAkKCwwPFyVKJUipEHkmgC9Wpeogyu3CLH/32CzrKb0y" |
| + |
| + got, err := sealWith(ephPriv, nonce, recipientPriv.PublicKey(), plaintext) |
| + if err != nil { |
| + t.Fatalf("sealWith: %v", err) |
| + } |
| + if got != wantEnvelope { |
| + t.Fatalf("envelope drifted from the pinned wire format:\n got: %s\n want: %s", got, wantEnvelope) |
| + } |
| + opened, err := Open(recipientPriv, got) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + if !bytes.Equal(opened, plaintext) { |
| + t.Fatalf("Open = %q, want %q", opened, plaintext) |
| + } |
| +} |
| + |
| +func TestSealOpenRoundTripRandomKeys(t *testing.T) { |
| + priv, pub, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + pubKey, err := ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + envelope, err := Seal(pubKey, []byte("hello")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + got, err := Open(privKey, envelope) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + if string(got) != "hello" { |
| + t.Fatalf("Open = %q, want hello", got) |
| + } |
| +} |
| + |
| +func TestOpenRejectsTamperedCiphertext(t *testing.T) { |
| + priv, pub, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + pubKey, err := ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + envelope, err := Seal(pubKey, []byte("hello")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + raw, err := base64.StdEncoding.DecodeString(envelope) |
| + if err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + raw[len(raw)-1] ^= 0xFF // flip a ciphertext byte |
| + tampered := base64.StdEncoding.EncodeToString(raw) |
| + if _, err := Open(privKey, tampered); err == nil { |
| + t.Fatal("Open accepted tampered ciphertext, want an error") |
| + } |
| +} |
diff --git a/internal/appconfig/materialize.go b/internal/appconfig/materialize.go| new file mode 100644 |
| index 000000000..bafbb70d3 |
| --- /dev/null |
| +++ b/internal/appconfig/materialize.go |
| @@ -0,0 +1,67 @@ |
| +// Rendering a Manifest into the KEY=value lines carlos-app@.service's |
| +// EnvironmentFile directive reads. |
| +package appconfig |
| + |
| +import ( |
| + "crypto/ecdh" |
| + "fmt" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// ValidVarValue reports whether val can be materialized into an |
| +// EnvironmentFile verbatim. systemd's parser is shell-like — it interprets |
| +// backslash escapes and single/double quotes, and drops a line outright |
| +// when a quote is unbalanced — so a value carrying any of those would be |
| +// silently corrupted on the host with nothing surfaced to the operator. |
| +// Deliberately a rejection and not a quoting scheme: getting |
| +// systemd-compatible escaping subtly wrong fails in exactly the same |
| +// silent way. One predicate shared by set-time validation (where the |
| +// operator can still see the plaintext and act) and RenderEnv (defense in |
| +// depth for manifests written before the set-time check existed), so the |
| +// two can never diverge. |
| +func ValidVarValue(val string) error { |
| + if strings.ContainsAny(val, "\n\x00\\'\"") { |
| + return fmt.Errorf("value contains a backslash, quote, newline or NUL, none of which are safely representable in an EnvironmentFile") |
| + } |
| + return nil |
| +} |
| + |
| +// RenderEnv decrypts m's secret entries with priv and renders the whole |
| +// manifest as EnvironmentFile-shaped `KEY=value\n` lines, keys sorted so |
| +// the output is deterministic and diffable against what's already on |
| +// disk. priv may be nil iff m has no "secret" entries (callers that know |
| +// they're only rendering plain env vars, e.g. a future env-only path, |
| +// don't need a key in hand). |
| +func RenderEnv(m *Manifest, priv *ecdh.PrivateKey) ([]byte, error) { |
| + keys := make([]string, 0, len(m.Vars)) |
| + for k := range m.Vars { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + var b strings.Builder |
| + for _, k := range keys { |
| + v := m.Vars[k] |
| + var val string |
| + switch v.Kind { |
| + case "env": |
| + val = v.Value |
| + case "secret": |
| + if priv == nil { |
| + return nil, fmt.Errorf("var %s: secret entry but no decryption key was supplied", k) |
| + } |
| + plain, err := Open(priv, v.Envelope) |
| + if err != nil { |
| + return nil, fmt.Errorf("decrypt %s: %w", k, err) |
| + } |
| + val = string(plain) |
| + default: |
| + return nil, fmt.Errorf("var %s: unknown kind %q", k, v.Kind) |
| + } |
| + if err := ValidVarValue(val); err != nil { |
| + return nil, fmt.Errorf("var %s: %w", k, err) |
| + } |
| + fmt.Fprintf(&b, "%s=%s\n", k, val) |
| + } |
| + return []byte(b.String()), nil |
| +} |
diff --git a/internal/appconfig/materialize_test.go b/internal/appconfig/materialize_test.go| new file mode 100644 |
| index 000000000..fcc5a5e86 |
| --- /dev/null |
| +++ b/internal/appconfig/materialize_test.go |
| @@ -0,0 +1,86 @@ |
| +// internal/appconfig/materialize_test.go |
| +package appconfig |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRenderEnvMergesPlainAndDecryptedSecrets(t *testing.T) { |
| + priv, pub, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + pubKey, err := ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + envelope, err := Seal(pubKey, []byte("s3cr3t")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{ |
| + "LOG_LEVEL": {Kind: "env", Value: "debug"}, |
| + "SES_SECRET": {Kind: "secret", Envelope: envelope}, |
| + }} |
| + got, err := RenderEnv(m, privKey) |
| + if err != nil { |
| + t.Fatalf("RenderEnv: %v", err) |
| + } |
| + // Sorted by key, so the diff Sync computes against what's on disk is |
| + // deterministic — an unrelated key added or removed elsewhere in the |
| + // map must not reorder every line and force a spurious restart. |
| + want := "LOG_LEVEL=debug\nSES_SECRET=s3cr3t\n" |
| + if string(got) != want { |
| + t.Fatalf("RenderEnv = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestRenderEnvRejectsValuesSystemdWouldMangle(t *testing.T) { |
| + // systemd's EnvironmentFile parser is shell-like: it eats backslash |
| + // escapes and quotes, and drops a line outright on an unbalanced quote. |
| + // A value carrying any of those would be silently corrupted on the host, |
| + // so reject it here, where an operator still sees the error. |
| + for name, val := range map[string]string{ |
| + "newline": "line1\nline2", |
| + "nul": "a\x00b", |
| + "backslash": `C:\Users\svc`, |
| + "singlequote": "it's", |
| + "doublequote": `say "hi"`, |
| + "unbalanced": `p4ss"word`, |
| + "mixed": `a\'b"c`, |
| + } { |
| + m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{ |
| + "BAD": {Kind: "env", Value: val}, |
| + }} |
| + _, err := RenderEnv(m, nil) |
| + if err == nil { |
| + t.Errorf("%s: RenderEnv accepted %q, want an error", name, val) |
| + continue |
| + } |
| + if !strings.Contains(err.Error(), "BAD") { |
| + t.Errorf("%s: error %q does not name the offending var", name, err) |
| + } |
| + } |
| +} |
| + |
| +func TestRenderEnvAcceptsOrdinaryPunctuation(t *testing.T) { |
| + // The rejection must stay narrow: everything systemd passes through |
| + // verbatim still renders, including the characters common in URLs and |
| + // base64 payloads. |
| + val := "postgres://u:p%40ss@host:5432/db?sslmode=require&x=a+b/c==" |
| + m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{ |
| + "DATABASE_URL": {Kind: "env", Value: val}, |
| + }} |
| + got, err := RenderEnv(m, nil) |
| + if err != nil { |
| + t.Fatalf("RenderEnv: %v", err) |
| + } |
| + if string(got) != "DATABASE_URL="+val+"\n" { |
| + t.Fatalf("RenderEnv = %q, want DATABASE_URL=%s", got, val) |
| + } |
| +} |
diff --git a/internal/appconfig/store.go b/internal/appconfig/store.go| new file mode 100644 |
| index 000000000..7fdd51b86 |
| --- /dev/null |
| +++ b/internal/appconfig/store.go |
| @@ -0,0 +1,162 @@ |
| +// CAS-guarded reads and writes of a Manifest, on top of release.Store — the |
| +// same read-modify-write-with-If-Match discipline release.Promote already |
| +// uses for channel pointers, without that function's signing (config |
| +// manifests aren't a fleet trust boundary the way a channel pointer is: |
| +// nothing executes because a manifest says so). |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Get fetches app's config manifest, an empty one (not an error) if it has |
| +// never been configured — a valid state for `list`, which has nothing to |
| +// print either way. |
| +func Get(ctx context.Context, s release.Store, acct, app string) (*Manifest, error) { |
| + m, found, err := getIfExists(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !found { |
| + return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil |
| + } |
| + return m, nil |
| +} |
| + |
| +// getIfExists is Get plus the one bit Get deliberately folds away: whether a |
| +// manifest object exists in the bucket at all. An empty manifest is |
| +// ambiguous on its own — "never configured" and "configured, then fully |
| +// unset" both read as zero vars — and Sync must tell them apart: the first |
| +// has to leave a host's hand-staged env file untouched, the second has to |
| +// converge it to empty. found==false means no object, and the returned |
| +// manifest is nil. |
| +func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manifest, bool, error) { |
| + if !release.ValidAccount(acct) { |
| + return nil, false, fmt.Errorf("config: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return nil, false, fmt.Errorf("config: invalid app %q", app) |
| + } |
| + raw, _, err := s.Get(ctx, Key(acct, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, false, nil |
| + } |
| + return nil, false, err |
| + } |
| + var m Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return nil, false, fmt.Errorf("config %s: %w", Key(acct, app), err) |
| + } |
| + // json.Unmarshal has already dropped any fields this binary doesn't |
| + // know about, so acting on a newer-schema manifest would silently |
| + // discard whatever those fields meant. Refuse instead. |
| + if m.Schema > 1 { |
| + return nil, false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", Key(acct, app), m.Schema) |
| + } |
| + return &m, true, nil |
| +} |
| + |
| +// getForWrite is Get plus the etag a caller needs to write back — a |
| +// not-found manifest reads as empty with etag "" (create-only Put). |
| +func getForWrite(ctx context.Context, s release.Store, acct, app string) (Manifest, string, error) { |
| + raw, etag, err := s.Get(ctx, Key(acct, app)) |
| + switch { |
| + case err == nil: |
| + var m Manifest |
| + if uerr := json.Unmarshal(raw, &m); uerr != nil { |
| + return Manifest{}, "", fmt.Errorf("config %s: %w", Key(acct, app), uerr) |
| + } |
| + // Same newer-schema refusal as getIfExists — writing back through |
| + // this path would downgrade the object to schema 1 minus the fields |
| + // unmarshal already dropped. |
| + if m.Schema > 1 { |
| + return Manifest{}, "", fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", Key(acct, app), m.Schema) |
| + } |
| + return m, etag, nil |
| + case errors.Is(err, os.ErrNotExist): |
| + return Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", nil |
| + default: |
| + return Manifest{}, "", err |
| + } |
| +} |
| + |
| +// apply reads, applies sets and unsets, and writes back via CAS. A racing |
| +// concurrent write surfaces as release.ErrCASConflict — callers report it |
| +// and let the operator re-run; there is no automatic retry, matching |
| +// release.Promote's own behavior. |
| +func apply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) error { |
| + if !release.ValidAccount(acct) { |
| + return fmt.Errorf("config: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return fmt.Errorf("config: invalid app %q", app) |
| + } |
| + for k, v := range sets { |
| + if !ValidVarName(k) { |
| + return fmt.Errorf("config: invalid var name %q", k) |
| + } |
| + // Env values are validated here, while the operator can still see |
| + // the plaintext — a bad value that only failed at render time would |
| + // stall the whole app's config convergence on every host, hours |
| + // later and out of sight. Secret values get the same check in the |
| + // CLI, before sealing; by this point they're opaque envelopes. |
| + if v.Kind == "env" { |
| + if err := ValidVarValue(v.Value); err != nil { |
| + return fmt.Errorf("config: var %s: %w", k, err) |
| + } |
| + } |
| + } |
| + m, etag, err := getForWrite(ctx, s, acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + if m.Vars == nil { |
| + m.Vars = map[string]Var{} |
| + } |
| + for _, k := range unsets { |
| + delete(m.Vars, k) |
| + } |
| + for k, v := range sets { |
| + m.Vars[k] = v |
| + } |
| + m.Schema, m.App = 1, app |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, Key(acct, app), raw, etag) |
| + return err |
| +} |
| + |
| +// SetEnv writes one or more plaintext config vars. |
| +func SetEnv(ctx context.Context, s release.Store, acct, app string, kv map[string]string) error { |
| + sets := make(map[string]Var, len(kv)) |
| + for k, v := range kv { |
| + sets[k] = Var{Kind: "env", Value: v} |
| + } |
| + return apply(ctx, s, acct, app, sets, nil) |
| +} |
| + |
| +// SetSecret writes one or more secrets. sealed values must already be |
| +// Seal-produced envelopes — this function stores them opaquely and never |
| +// sees plaintext. |
| +func SetSecret(ctx context.Context, s release.Store, acct, app string, sealed map[string]string) error { |
| + sets := make(map[string]Var, len(sealed)) |
| + for k, v := range sealed { |
| + sets[k] = Var{Kind: "secret", Envelope: v} |
| + } |
| + return apply(ctx, s, acct, app, sets, nil) |
| +} |
| + |
| +// Unset removes vars (env or secret) by key. Unsetting a key that isn't set |
| +// is a no-op success, matching heroku config:unset's idempotence. |
| +func Unset(ctx context.Context, s release.Store, acct, app string, keys []string) error { |
| + return apply(ctx, s, acct, app, nil, keys) |
| +} |
diff --git a/internal/appconfig/store_test.go b/internal/appconfig/store_test.go| new file mode 100644 |
| index 000000000..9a8a2aa79 |
| --- /dev/null |
| +++ b/internal/appconfig/store_test.go |
| @@ -0,0 +1,173 @@ |
| +// internal/appconfig/store_test.go |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestSetEnvThenGet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + m, err := Get(ctx, s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if v := m.Vars["LOG_LEVEL"]; v.Kind != "env" || v.Value != "debug" { |
| + t.Fatalf("LOG_LEVEL = %+v, want kind env value debug", v) |
| + } |
| +} |
| + |
| +func TestGetOnMissingManifestReturnsEmpty(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + m, err := Get(context.Background(), s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if len(m.Vars) != 0 { |
| + t.Fatalf("Vars = %v, want empty for a never-configured app", m.Vars) |
| + } |
| +} |
| + |
| +func TestUnsetRemovesVar(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil { |
| + t.Fatalf("Unset: %v", err) |
| + } |
| + m, err := Get(ctx, s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if _, ok := m.Vars["LOG_LEVEL"]; ok { |
| + t.Fatalf("LOG_LEVEL still present after Unset") |
| + } |
| +} |
| + |
| +func TestSetSecretStoresEnvelopeOnly(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetSecret(ctx, s, "bab", "keymail", map[string]string{"SES_SECRET": "opaque-envelope-bytes"}); err != nil { |
| + t.Fatalf("SetSecret: %v", err) |
| + } |
| + m, err := Get(ctx, s, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + v := m.Vars["SES_SECRET"] |
| + if v.Kind != "secret" || v.Envelope != "opaque-envelope-bytes" || v.Value != "" { |
| + t.Fatalf("SES_SECRET = %+v, want kind secret, envelope set, value empty", v) |
| + } |
| +} |
| + |
| +func TestSetRejectsInvalidVarName(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + err := SetEnv(context.Background(), s, "bab", "keymail", map[string]string{"has-hyphen": "x"}) |
| + if err == nil { |
| + t.Fatal("SetEnv accepted an invalid var name, want an error") |
| + } |
| +} |
| + |
| +// racingStore wraps a release.Store and lets a competing write land |
| +// between a reader's Get and its subsequent Put — the interleaving apply's |
| +// read-modify-write must lose, visibly. |
| +type racingStore struct { |
| + release.Store |
| + betweenGetAndPut func() |
| +} |
| + |
| +func (r *racingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + raw, etag, err := r.Store.Get(ctx, key) |
| + if r.betweenGetAndPut != nil { |
| + f := r.betweenGetAndPut |
| + r.betweenGetAndPut = nil |
| + f() |
| + } |
| + return raw, etag, err |
| +} |
| + |
| +// TestConcurrentWriteSurfacesCASConflict proves SetEnv's real |
| +// read-modify-write path surfaces ErrCASConflict (rather than retrying or |
| +// clobbering) when a competing write lands between its read and its write |
| +// — the same discipline release.Promote already uses for channel pointers. |
| +func TestConcurrentWriteSurfacesCASConflict(t *testing.T) { |
| + base := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := SetEnv(ctx, base, "bab", "keymail", map[string]string{"A": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + rs := &racingStore{Store: base, betweenGetAndPut: func() { |
| + // A second operator's write lands first, moving the object on. |
| + if err := SetEnv(ctx, base, "bab", "keymail", map[string]string{"B": "2"}); err != nil { |
| + t.Fatalf("SetEnv (racing writer): %v", err) |
| + } |
| + }} |
| + err := SetEnv(ctx, rs, "bab", "keymail", map[string]string{"C": "3"}) |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("SetEnv against a moved object = %v, want ErrCASConflict", err) |
| + } |
| + // The racing writer's value survived; the loser's never landed. |
| + m, err := Get(ctx, base, "bab", "keymail") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if _, ok := m.Vars["C"]; ok { |
| + t.Fatal("losing write landed anyway") |
| + } |
| + if v := m.Vars["B"]; v.Value != "2" { |
| + t.Fatalf("racing writer's var = %+v, want value 2", v) |
| + } |
| +} |
| + |
| +func TestSetEnvRejectsUnrepresentableValue(t *testing.T) { |
| + // systemd's EnvironmentFile parser mangles backslashes and quotes; |
| + // those must be refused at set time, where the operator can still see |
| + // the plaintext — not hours later in the adopt log. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + for _, bad := range []string{`back\slash`, `dou"ble`, `sin'gle`, "new\nline", "nul\x00byte"} { |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"V": bad}); err == nil { |
| + t.Fatalf("SetEnv accepted %q, want a rejection", bad) |
| + } |
| + } |
| + // The rejection stays narrow: the shapes real credentials take must pass. |
| + for _, good := range []string{"postgres://u:p@h/db?sslmode=require", "dGVzdA==", "100%", "a+b c"} { |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"V": good}); err != nil { |
| + t.Fatalf("SetEnv rejected %q: %v", good, err) |
| + } |
| + } |
| +} |
| + |
| +func TestNewerSchemaIsRefusedNotDowngraded(t *testing.T) { |
| + // json.Unmarshal drops fields this binary doesn't know; acting on a |
| + // newer manifest would silently discard them — and a write-back would |
| + // downgrade the object. Both paths must refuse instead. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + raw := []byte(`{"schema": 2, "app": "keymail", "vars": {}}`) |
| + if _, err := s.Put(ctx, Key("bab", "keymail"), raw, ""); err != nil { |
| + t.Fatalf("Put: %v", err) |
| + } |
| + if _, err := Get(ctx, s, "bab", "keymail"); err == nil { |
| + t.Fatal("Get read a schema-2 manifest without complaint, want a refusal") |
| + } |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "1"}); err == nil { |
| + t.Fatal("SetEnv wrote over a schema-2 manifest, want a refusal") |
| + } |
| + got, _, err := s.Get(ctx, Key("bab", "keymail")) |
| + if err != nil { |
| + t.Fatalf("Get raw: %v", err) |
| + } |
| + if string(got) != string(raw) { |
| + t.Fatalf("schema-2 object was modified: %s", got) |
| + } |
| +} |
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go| new file mode 100644 |
| index 000000000..3bc7d0112 |
| --- /dev/null |
| +++ b/internal/appconfig/sync.go |
| @@ -0,0 +1,197 @@ |
| +// Sync is the host-side convergence sweep: for every app the box's |
| +// registry runs, fetch its config manifest once and materialize the |
| +// result into every route's EnvironmentFile. Shaped after release.Pass |
| +// (resolve once per group, converge once per route) but with none of that |
| +// function's staging/symlink/bake machinery — config isn't versioned. |
| +package appconfig |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdh" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// DefaultEnvDir is where carlos-app@.service's EnvironmentFile directive |
| +// looks for a route's env file in production |
| +// (EnvironmentFile=-/etc/carlos/apps/%i.env, %i = route Host). |
| +const DefaultEnvDir = "/etc/carlos/apps" |
| + |
| +// Sync fetches apps/<acct>/<app>/config.json once per distinct |
| +// (Account, App) among reg's routes, then for every route running that |
| +// app renders <envDir>/<route.Host>.env and, if it changed, writes it and |
| +// restarts that route's unit via restart. Die-free by design, like |
| +// release.Pass: a fetch or render failure for one app logs and continues |
| +// rather than aborting the whole sweep. |
| +func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + type groupKey struct{ acct, app string } |
| + manifests := map[groupKey]*Manifest{} |
| + for _, rt := range routes { |
| + if !syncable(rt) { |
| + continue |
| + } |
| + k := groupKey{release.RouteAccount(rt), rt.App} |
| + if _, done := manifests[k]; done { |
| + continue |
| + } |
| + // getIfExists, not Get: an app with no manifest object in the bucket |
| + // at all is left entirely alone (nil manifest => skipped below), so |
| + // turning this feature on does not truncate env files that are still |
| + // hand-staged on the host. Get folds not-found into an empty |
| + // manifest, which would be indistinguishable from a real, fully |
| + // unset one — and those must converge. |
| + m, found, err := getIfExists(ctx, s, k.acct, k.app) |
| + if err != nil { |
| + fmt.Printf(" %s/%s: config fetch failed: %v\n", k.acct, k.app, err) |
| + manifests[k] = nil |
| + continue |
| + } |
| + if !found { |
| + manifests[k] = nil |
| + continue |
| + } |
| + manifests[k] = m |
| + } |
| + for _, rt := range routes { |
| + if !syncable(rt) { |
| + continue |
| + } |
| + m := manifests[groupKey{release.RouteAccount(rt), rt.App}] |
| + if m == nil { |
| + continue |
| + } |
| + // Only unit-backed routes read /etc/carlos/apps — an exec-backed |
| + // route's env comes from <DataDir>/<host>.env, which the activator |
| + // owns (it also carries CARLOS_ADMIN_TOKEN, so it can't be blindly |
| + // overwritten here). Materializing for one would write a file |
| + // nothing reads and log a restart that restarted nothing; say so |
| + // instead of succeeding silently. Spec §5 records this limitation. |
| + if rt.Backing != registry.BackingUnit { |
| + fmt.Printf(" %s: has bucket config but is exec-backed — config sync only reaches carlos-app@ unit routes, skipped\n", rt.Host) |
| + continue |
| + } |
| + syncHost(rt, m, priv, envDir, restart) |
| + } |
| + // An empty route list is never authority to delete anything: |
| + // registry.Open silently creates an empty database when handed a wrong |
| + // path or an unmounted /data, and reaping against that would wipe every |
| + // env file on the box — including hand-staged ones this feature |
| + // promises not to touch. |
| + if len(routes) == 0 { |
| + return nil |
| + } |
| + reap(routes, envDir) |
| + return nil |
| +} |
| + |
| +// reap deletes <envDir>/<host>.env for any host with no route row at all — |
| +// a decommissioned route's env file holds decrypted secrets and nothing |
| +// else on the box removes it. Keyed off the full route list, not the |
| +// syncable subset, so a route that is merely mid-provisioning keeps its |
| +// file. Routes are the source of truth for what runs here (the same |
| +// assumption release adoption's symlink repointing already makes), so a |
| +// host with zero rows is reaped immediately, with no grace period. A |
| +// missing or unreadable env dir just means there is nothing to reap. |
| +func reap(routes []registry.Route, envDir string) { |
| + ents, err := os.ReadDir(envDir) |
| + if err != nil { |
| + return |
| + } |
| + live := make(map[string]bool, len(routes)) |
| + for _, rt := range routes { |
| + live[rt.Host] = true |
| + } |
| + for _, e := range ents { |
| + if e.IsDir() { |
| + continue |
| + } |
| + // A leftover atomic-write temp (crashed between write and rename) |
| + // holds decrypted secrets and is stale by construction once reap |
| + // runs — this pass's own writes all renamed or failed earlier, |
| + // sequentially. |
| + if strings.HasSuffix(e.Name(), ".env.tmp") { |
| + if err := os.Remove(filepath.Join(envDir, e.Name())); err != nil { |
| + fmt.Printf(" %s: remove stale env temp file: %v\n", e.Name(), err) |
| + } |
| + continue |
| + } |
| + if !strings.HasSuffix(e.Name(), ".env") { |
| + continue |
| + } |
| + host := strings.TrimSuffix(e.Name(), ".env") |
| + if live[host] { |
| + continue |
| + } |
| + if err := os.Remove(filepath.Join(envDir, e.Name())); err != nil { |
| + fmt.Printf(" %s: remove orphaned env file: %v\n", host, err) |
| + continue |
| + } |
| + fmt.Printf(" %s: no route on this box, removed its env file\n", host) |
| + } |
| +} |
| + |
| +// syncable mirrors release.adoptable: lifecycle-managed instances of a |
| +// known app, excluding platform-minted alias routes (an alias shares its |
| +// canonical route's process and therefore its canonical route's config — |
| +// see release.adoptable's own doc comment for why aliases are excluded |
| +// from adoption the same way). |
| +func syncable(rt registry.Route) bool { |
| + return rt.Kind == "instance" && rt.App != "" && !rt.Platform && |
| + (rt.Status == "" || rt.Status == "ready") |
| +} |
| + |
| +// syncHost converges one route's env file. It is called only for apps whose |
| +// manifest object really exists in the bucket, so an empty manifest here |
| +// means "every var was unset" and converges the file to empty — the |
| +// "never configured at all" case is filtered out by Sync, before this. |
| +func syncHost(rt registry.Route, m *Manifest, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) { |
| + // The host is joined into a root-written path; a registry row carrying |
| + // a separator must never place a file outside envDir. Same trust level |
| + // as release's live/<host> symlinks, but this one is one line to pin. |
| + if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) { |
| + fmt.Printf(" %q: not a bare hostname, refusing to derive an env file path from it\n", rt.Host) |
| + return |
| + } |
| + path := filepath.Join(envDir, rt.Host+".env") |
| + cur, err := os.ReadFile(path) |
| + existed := err == nil |
| + if err != nil && !os.IsNotExist(err) { |
| + fmt.Printf(" %s: read %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + rendered, err := RenderEnv(m, priv) |
| + if err != nil { |
| + fmt.Printf(" %s: render config: %v\n", rt.Host, err) |
| + return |
| + } |
| + if existed && bytes.Equal(cur, rendered) { |
| + return // up to date — stay quiet on the periodic tick |
| + } |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, rendered, 0o600); err != nil { |
| + fmt.Printf(" %s: write %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + os.Remove(tmp) // don't leave rendered secrets behind |
| + fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err) |
| + return |
| + } |
| + // No rt.Hibernate special case on purpose: try-restart is a no-op on an |
| + // inactive unit, so a hibernated route's file converges without waking it. |
| + if err := restart(rt.Host); err != nil { |
| + fmt.Printf(" %s: config updated but restart failed: %v\n", rt.Host, err) |
| + return |
| + } |
| + fmt.Printf(" %s: config updated, restarted\n", rt.Host) |
| +} |
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go| new file mode 100644 |
| index 000000000..0a0c4aced |
| --- /dev/null |
| +++ b/internal/appconfig/sync_test.go |
| @@ -0,0 +1,477 @@ |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func testRegistry(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + return reg |
| +} |
| + |
| +func TestSyncWritesFileAndRestartsOnChange(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + |
| + priv, pub, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + pubKey, err := ParsePublicKey(pub, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + envelope, err := Seal(pubKey, []byte("shh")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := SetSecret(ctx, s, "bab", "keymail", map[string]string{"SES_SECRET": envelope}); err != nil { |
| + t.Fatalf("SetSecret: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "keymail.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", |
| + Backing: registry.BackingUnit, |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + |
| + var restarted []string |
| + restart := func(host string) error { restarted = append(restarted, host); return nil } |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + |
| + got, err := os.ReadFile(filepath.Join(envDir, "keymail.example.com.env")) |
| + if err != nil { |
| + t.Fatalf("read rendered env: %v", err) |
| + } |
| + want := "LOG_LEVEL=debug\nSES_SECRET=shh\n" |
| + if string(got) != want { |
| + t.Fatalf("rendered env = %q, want %q", got, want) |
| + } |
| + if len(restarted) != 1 || restarted[0] != "keymail.example.com" { |
| + t.Fatalf("restarted = %v, want exactly [keymail.example.com]", restarted) |
| + } |
| + |
| + // A second pass with nothing changed must not write or restart again. |
| + restarted = nil |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + t.Fatalf("Sync (second pass): %v", err) |
| + } |
| + if len(restarted) != 0 { |
| + t.Fatalf("second pass restarted %v, want none", restarted) |
| + } |
| +} |
| + |
| +func TestSyncSkipsRoutesWithNoConfigEverSet(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "bare.example.com", Kind: "instance", App: "bareapp", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + var restarted []string |
| + restart := func(host string) error { restarted = append(restarted, host); return nil } |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(envDir, "bare.example.com.env")); !os.IsNotExist(err) { |
| + t.Fatalf("expected no env file for a route with no config ever set, stat err = %v", err) |
| + } |
| + if len(restarted) != 0 { |
| + t.Fatalf("restarted = %v, want none", restarted) |
| + } |
| +} |
| + |
| +func TestSyncLeavesHandStagedFileAloneWhenNoManifestExists(t *testing.T) { |
| + // The migration case: an app whose env file is still hand-staged on the |
| + // host, with nothing in the bucket for it yet. Turning this feature on |
| + // (setting CARLOS_SECRETS_KEY) must not truncate that file — "no |
| + // manifest object at all" is a no-op, not "converge to empty". |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "legacy.example.com", Kind: "instance", App: "legacyapp", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + path := filepath.Join(envDir, "legacy.example.com.env") |
| + staged := "DATABASE_URL=postgres://staged\nAPI_TOKEN=hand-written\n" |
| + if err := os.WriteFile(path, []byte(staged), 0o600); err != nil { |
| + t.Fatalf("stage env file: %v", err) |
| + } |
| + |
| + var restarted []string |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error { |
| + restarted = append(restarted, h) |
| + return nil |
| + }); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + |
| + got, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read staged env: %v", err) |
| + } |
| + if string(got) != staged { |
| + t.Fatalf("hand-staged env file = %q, want it untouched (%q)", got, staged) |
| + } |
| + if len(restarted) != 0 { |
| + t.Fatalf("restarted = %v, want none", restarted) |
| + } |
| +} |
| + |
| +func TestSyncConvergesToEmptyAfterEveryVarIsUnset(t *testing.T) { |
| + // The mirror of the case above: the manifest object DOES exist, it just |
| + // has no vars left. That is a real, operator-intended state and must |
| + // converge — the file is emptied and the unit restarted. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "keymail.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", |
| + Backing: registry.BackingUnit, |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + var restarted []string |
| + restart := func(h string) error { restarted = append(restarted, h); return nil } |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + path := filepath.Join(envDir, "keymail.example.com.env") |
| + if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\n" { |
| + t.Fatalf("after set: env = %q, err = %v; want LOG_LEVEL=debug", got, err) |
| + } |
| + |
| + if err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil { |
| + t.Fatalf("Unset: %v", err) |
| + } |
| + restarted = nil |
| + if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil { |
| + t.Fatalf("Sync (after unset): %v", err) |
| + } |
| + got, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read env after unset: %v", err) |
| + } |
| + if len(got) != 0 { |
| + t.Fatalf("after full unset: env = %q, want empty", got) |
| + } |
| + if len(restarted) != 1 || restarted[0] != "keymail.example.com" { |
| + t.Fatalf("restarted = %v, want exactly [keymail.example.com]", restarted) |
| + } |
| +} |
| + |
| +func TestSyncReapsEnvFilesForHostsWithNoRoute(t *testing.T) { |
| + // A decommissioned route's env file may hold decrypted secrets; nothing |
| + // else on the box removes it. A host that still has a route row keeps |
| + // its file even if it has no config set at all. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "live.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + // A route that exists but is not syncable (mid-provisioning) must also |
| + // keep its file — reaping keys off the full route list, not syncable(). |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "pending.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "provisioning", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + files := map[string]string{ |
| + "live.example.com.env": "KEEP=1\n", |
| + "pending.example.com.env": "KEEP=2\n", |
| + "gone.example.com.env": "SECRET=leftover\n", |
| + } |
| + for name, body := range files { |
| + if err := os.WriteFile(filepath.Join(envDir, name), []byte(body), 0o600); err != nil { |
| + t.Fatalf("stage %s: %v", name, err) |
| + } |
| + } |
| + |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + |
| + if _, err := os.Stat(filepath.Join(envDir, "gone.example.com.env")); !os.IsNotExist(err) { |
| + t.Fatalf("orphaned env file survived Sync, stat err = %v", err) |
| + } |
| + for _, name := range []string{"live.example.com.env", "pending.example.com.env"} { |
| + got, err := os.ReadFile(filepath.Join(envDir, name)) |
| + if err != nil { |
| + t.Fatalf("%s: want it kept, read err = %v", name, err) |
| + } |
| + if string(got) != files[name] { |
| + t.Fatalf("%s = %q, want it untouched (%q)", name, got, files[name]) |
| + } |
| + } |
| +} |
| + |
| +func TestSyncEmptyRegistryLeavesEnvFilesAlone(t *testing.T) { |
| + // registry.Open silently creates an empty database when handed a wrong |
| + // path or an unmounted /data — "successful read, zero routes" is an |
| + // easily reachable state, not proof the box runs nothing. Reaping |
| + // against it would wipe every env file on the box as root. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) // empty: no routes ever upserted |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + staged := "DATABASE_URL=postgres://staged\n" |
| + path := filepath.Join(envDir, "handstaged.example.com.env") |
| + if err := os.WriteFile(path, []byte(staged), 0o600); err != nil { |
| + t.Fatalf("stage env file: %v", err) |
| + } |
| + |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + |
| + got, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("env file gone after Sync against an empty registry: %v", err) |
| + } |
| + if string(got) != staged { |
| + t.Fatalf("env file = %q, want it untouched (%q)", got, staged) |
| + } |
| +} |
| + |
| +func TestSyncSkipsExecBackedRoutes(t *testing.T) { |
| + // An exec-backed route (Backing == "", the fleet default) reads |
| + // <DataDir>/<host>.env, not /etc/carlos/apps — materializing there |
| + // would write a file nothing reads and claim a restart that restarted |
| + // nothing. Sync must skip it (with a log line, not silence). |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "execshape.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + var restarted []string |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error { |
| + restarted = append(restarted, h) |
| + return nil |
| + }); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(envDir, "execshape.example.com.env")); !os.IsNotExist(err) { |
| + t.Fatalf("expected no env file for an exec-backed route, stat err = %v", err) |
| + } |
| + if len(restarted) != 0 { |
| + t.Fatalf("restarted = %v, want none", restarted) |
| + } |
| +} |
| + |
| +func TestSyncReapsStaleTmpFiles(t *testing.T) { |
| + // A crash between write and rename leaves <host>.env.tmp holding |
| + // decrypted secrets; by the time reap runs, any temp file is stale by |
| + // construction and must go — even one whose host still has a route. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "live.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", |
| + Backing: registry.BackingUnit, |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + for _, name := range []string{"live.example.com.env.tmp", "gone.example.com.env.tmp"} { |
| + if err := os.WriteFile(filepath.Join(envDir, name), []byte("SECRET=leftover\n"), 0o600); err != nil { |
| + t.Fatalf("stage %s: %v", name, err) |
| + } |
| + } |
| + |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + |
| + for _, name := range []string{"live.example.com.env.tmp", "gone.example.com.env.tmp"} { |
| + if _, err := os.Stat(filepath.Join(envDir, name)); !os.IsNotExist(err) { |
| + t.Fatalf("%s survived Sync, stat err = %v", name, err) |
| + } |
| + } |
| +} |
| + |
| +func TestSyncHostRefusesNonBareHostname(t *testing.T) { |
| + // A registry row whose Host carries a path separator must never place a |
| + // root-written file outside envDir. |
| + envDir := filepath.Join(t.TempDir(), "apps") |
| + if err := os.Mkdir(envDir, 0o755); err != nil { |
| + t.Fatalf("mkdir: %v", err) |
| + } |
| + m := &Manifest{Schema: 1, App: "evil", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}} |
| + for _, host := range []string{"../escape", "a/b", "", ".", ".."} { |
| + syncHost(registry.Route{Host: host}, m, nil, envDir, func(string) error { return nil }) |
| + } |
| + if _, err := os.Stat(filepath.Join(envDir, "..", "escape.env")); !os.IsNotExist(err) { |
| + t.Fatalf("a path-escaping host wrote outside envDir, stat err = %v", err) |
| + } |
| + ents, err := os.ReadDir(envDir) |
| + if err != nil { |
| + t.Fatalf("read envDir: %v", err) |
| + } |
| + if len(ents) != 0 { |
| + t.Fatalf("envDir = %v, want nothing written for non-bare hostnames", ents) |
| + } |
| +} |
| + |
| +func TestSyncSurvivesAMissingEnvDir(t *testing.T) { |
| + // A box that has never materialized anything has no env dir; the reap |
| + // step must not turn that into an error. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := filepath.Join(t.TempDir(), "never-created") |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "bare.example.com", Kind: "instance", App: "bareapp", Account: "bab", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Sync with a missing env dir: %v", err) |
| + } |
| +} |
| + |
| +func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) { |
| + // One app, two simultaneous routes (e.g. a stable route and a canary |
| + // route are two different Hosts) — the shared config must land in |
| + // both routes' env files, per spec §5's per-route-host materialization. |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + for _, host := range []string{"stable.keymail.example.com", "canary.keymail.example.com"} { |
| + if err := reg.Upsert(registry.Route{ |
| + Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready", |
| + Backing: registry.BackingUnit, |
| + }); err != nil { |
| + t.Fatalf("Upsert %s: %v", host, err) |
| + } |
| + } |
| + if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + for _, host := range []string{"stable.keymail.example.com", "canary.keymail.example.com"} { |
| + got, err := os.ReadFile(filepath.Join(envDir, host+".env")) |
| + if err != nil { |
| + t.Fatalf("%s: read rendered env: %v", host, err) |
| + } |
| + if string(got) != "LOG_LEVEL=debug\n" { |
| + t.Fatalf("%s: rendered env = %q, want LOG_LEVEL=debug", host, got) |
| + } |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 07780366e..0290fa70d 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -79,6 +79,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/lease" |
| @@ -594,6 +595,11 @@ func openDeployStore(ctx context.Context) (release.Store, error) { |
| // RunAdopt is the `carlos adopt` verb: one immediate adoption pass — the |
| // carlos-adopt.timer unit's ExecStart, and an operator's "converge now". |
| +// Runs as root (see carlos-adopt.service): this is the ONLY place config |
| +// sync may run, because it writes /etc/carlos/apps and restarts units — |
| +// the same filesystem-write shape release staging needs root for, and |
| +// exactly what the sandboxed carlos-edge.service's in-process adopt loop |
| +// (ProtectSystem=strict, ReadWritePaths=/data /run/carlos only) cannot do. |
| func RunAdopt(args []string) { |
| fs := flag.NewFlagSet("adopt", flag.ExitOnError) |
| regPath := fs.String("registry", filepath.Join(dataDir(), "registry.db"), "registry database") |
| @@ -611,6 +617,20 @@ func RunAdopt(args []string) { |
| if err != nil { |
| log.Fatalf("adopt: %v", err) |
| } |
| + // Config sync is optional — inert until CARLOS_SECRETS_KEY is set, the |
| + // same convention every other optional loop in this package follows. It |
| + // runs before release adoption so a newly adopted route's very first |
| + // start already sees its env file, and a pass carrying both a release |
| + // and a config change never starts the new binary against stale config. |
| + if raw := os.Getenv("CARLOS_SECRETS_KEY"); raw != "" { |
| + secretsPriv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| + if err != nil { |
| + log.Printf("adopt: config sync disabled: %v", err) |
| + } else if err := appconfig.Sync(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restartRoute); err != nil { |
| + log.Printf("adopt: config sync: %v", err) |
| + } |
| + } |
| + |
| if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute); err != nil { |
| log.Fatalf("adopt: %v", err) |
| } |
diff --git a/main.go b/main.go| index 36f67a4bb..5f76ae4bb 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -43,6 +43,10 @@ func main() { |
| RunAuth(args) |
| case "accounts": |
| RunAccounts(args) |
| + case "env": |
| + RunEnv(args) |
| + case "secrets": |
| + RunSecrets(args) |
| case "release-keygen": |
| runKeygen(args) |
| case "ops": |
| @@ -76,6 +80,8 @@ commands: |
| auth login | whoami | logout — who this terminal is |
| (headless? don't log in: pass -owner instead) |
| accounts create | list | migrate — the tenancy primitive |
| + env set | unset | list — plain per-app config vars |
| + secrets set | unset | list | genkey — sealed per-app secrets |
| ship | promote | channels publish and promote releases (-account <sqid|name>) |
| promote climbs the ladder canary -> edge -> beta |
| -> stable; -hotfix is the recorded bypass |