carlos / platform Public

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.
Paul Campbell 68d066291dea8bc0d4d3bd2b43866835eab8ed6e parent 98377f2
17 files changed, +3900 −0
  • README.md +35 −0
  • appconfig_cli.go +249 −0
  • docs/superpowers/plans/2026-08-02-app-config-secrets.md +1728 −0
  • docs/superpowers/specs/2026-08-02-app-config-secrets-design.md +225 −0
  • e2e_test.go +126 −0
  • internal/appconfig/appconfig.go +50 −0
  • internal/appconfig/appconfig_test.go +26 −0
  • internal/appconfig/crypto.go +159 −0
  • internal/appconfig/crypto_test.go +114 −0
  • internal/appconfig/materialize.go +67 −0
  • internal/appconfig/materialize_test.go +86 −0
  • internal/appconfig/store.go +162 −0
  • internal/appconfig/store_test.go +173 −0
  • internal/appconfig/sync.go +197 −0
  • internal/appconfig/sync_test.go +477 −0
  • internal/host/agent.go +20 −0
  • main.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