// 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) } return getIfExistsAt(ctx, s, Key(acct, app)) } // getIfExistsAt is getIfExists generalized to any manifest key — Key(acct, // app) for the default bundle, EnvironmentKey(acct, app, name) for a named // one (see environments.go), InstanceConfigKey(acct, app, host) for an // instance overlay (instances.go). Same shape either way: all three are the // identical manifest format at sibling bucket paths, so one read path // serves them. func getIfExistsAt(ctx context.Context, s release.Store, key string) (*Manifest, bool, error) { m, _, found, err := getIfExistsAtETag(ctx, s, key) return m, found, err } // getIfExistsAtETag is getIfExistsAt plus the object's etag, which the // instance layer's reader needs and the other two bundles' readers do not: // the delivery ack names the etag of the overlay the agent rendered // (senders design §6.5), and an etag dropped here could not be recovered // later without a second read racing the first. func getIfExistsAtETag(ctx context.Context, s release.Store, key string) (*Manifest, string, bool, error) { raw, etag, err := s.Get(ctx, key) 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, 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, m.Schema) } return &m, etag, true, nil } // getForWriteAt is Get plus the two bits a caller needs to write back: the // etag (a not-found manifest reads as empty with etag "", a create-only Put) // and whether the object exists at all. found is NOT inferable from the etag // — a store is free to hand back an empty etag for an object that exists — // and applyAt's refusal to create a bundle on an unset-only call turns on it. // Generalized to any manifest key; see getIfExistsAt's doc comment for why // one path serves both the default bundle and a named environment. func getForWriteAt(ctx context.Context, s release.Store, key, app string) (Manifest, string, bool, error) { raw, etag, err := s.Get(ctx, key) switch { case err == nil: var m Manifest if uerr := json.Unmarshal(raw, &m); uerr != nil { return Manifest{}, "", false, fmt.Errorf("config %s: %w", key, 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{}, "", false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema) } return m, etag, true, nil case errors.Is(err, os.ErrNotExist): return Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", false, nil default: return Manifest{}, "", false, err } } // apply reads, applies sets and unsets, and writes back via CAS, reporting // how many of unsets were actually present. 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. // // On success it also touches the app's generation object (generation.go), so // the change is visible to the agent's fast loop within seconds rather than // at the next hourly sweep. Best-effort by construction: the manifest write // has already landed by then, so a failed touch is logged, never returned // (see touchLogged). func apply(ctx context.Context, s release.Store, acct, app, bundle string, sets map[string]Var, unsets []string) (int, error) { if !release.ValidAccount(acct) { return 0, fmt.Errorf("config: invalid account %q", acct) } if !release.ValidApp(app) { return 0, fmt.Errorf("config: invalid app %q", app) } removed, _, err := applyAt(ctx, s, Key(acct, app), app, bundle, sets, unsets) if err != nil { return 0, err } touchLogged(ctx, s, acct, app) return removed, nil } // applyAt is apply generalized to any manifest key — see getIfExistsAt's doc // comment for why one path serves both the default bundle and a named // environment. Callers validate acct/app/environment-name themselves before // reaching here, same division as apply does for Key(acct, app). bundle is // how to name this object to a human ("default", or the environment name); // it never affects which key is written. // // An unset-only call against an object that does not exist is REFUSED, not // silently turned into a create (2026-08-02 review, CRITICAL 2). Creating it // would mint an empty manifest, and an empty manifest that EXISTS means // "configured, then fully unset" by this package's own contract — which is // exactly the state Sync converges every one of that app's route env files // to empty for, as root, restarting each unit. It also minted phantom // environment bundles that then satisfied `carlos route -environment`'s // existence rail. Sets still create, which is how a bundle is meant to come // into being. func applyAt(ctx context.Context, s release.Store, key, app, bundle string, sets map[string]Var, unsets []string) (int, string, error) { for k, v := range sets { if !ValidVarName(k) { return 0, "", 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 0, "", fmt.Errorf("config: var %s: %w", k, err) } } } m, etag, found, err := getForWriteAt(ctx, s, key, app) if err != nil { return 0, "", err } if !found && len(sets) == 0 { return 0, "", fmt.Errorf("config: %s has no %s bundle — nothing to unset", app, bundle) } if m.Vars == nil { m.Vars = map[string]Var{} } removed := 0 for _, k := range unsets { if _, ok := m.Vars[k]; ok { removed++ } 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 0, "", err } // The new etag is returned because the instance layer's writer (design // §6.5) has to hand it to whoever will later compare it with an agent's // delivery ack. The other two bundles' callers ignore it. newETag, err := s.Put(ctx, key, raw, etag) if err != nil { return 0, "", err } return removed, newETag, nil } // DefaultBundle is how the default bundle (config.json) is named to a human // in messages — the same vocabulary ValidEnvironmentName reserves and the CLI // prints, kept in one place so an error from this package and a `list` header // can never drift apart. const DefaultBundle = "default" // SetEnv writes one or more plaintext config vars. func claimRawSetEnv(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} } _, err := apply(ctx, s, acct, app, DefaultBundle, sets, nil) return err } // SetSecret writes one or more secrets. sealed values must already be // Seal-produced envelopes — this function stores them opaquely and never // sees plaintext. func claimRawSetSecret(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} } _, err := apply(ctx, s, acct, app, DefaultBundle, sets, nil) return err } // Unset removes vars (env or secret) by key from the default bundle, // returning how many of keys were actually there. Unsetting a key that isn't // set is a no-op success, matching heroku config:unset's idempotence — // unsetting from a bundle that does not EXIST is not (see applyAt). func claimRawUnset(ctx context.Context, s release.Store, acct, app string, keys []string) (int, error) { return apply(ctx, s, acct, app, DefaultBundle, nil, keys) } // SetExecDelivery writes (`file`) or clears (`""`) the DEFAULT bundle's // exec_delivery field — issue #18's TCP-addr exec route opt-in (see // Manifest.ExecDelivery). The app-level door, unchanged in behaviour and // signature; SetExecDeliveryAt is the general one. func claimRawSetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) error { return SetExecDeliveryAt(ctx, s, acct, app, "", value) } // SetExecDeliveryAt writes or clears exec_delivery in ONE bundle: the // default bundle (envName == "") or a named environment's, via the same // CAS read-modify-write path SetEnv/SetSecret/Unset use. value must be // ValidExecDelivery; anything else is refused here, before it ever reaches // the bucket, mirroring applyAt's var-name/var-value validation. // // Environment scope is issue #195 (ruled 2026-08-16). The app-level flag // answered "does this APP get files", so opting one relay route in wrote a // file for every router route of the same app too — a fleet-wide switch // thrown to deliver one service's credential. The flag now lives wherever // the route's config comes from, and clearing it in a named bundle means // INHERIT the app default, never "off": a bundle that says nothing has not // asked for delivery to stop. Route scope is still refused — see // Manifest.ExecDelivery. // // Turning the field off against a bundle that does not exist is refused, // not silently turned into a create — the same "unset-only against a // nonexistent object" refusal applyAt documents, for the same reason: // minting an empty bundle just to hold an absent flag would satisfy // `carlos route -environment`'s existence rail and Sync's "configured, // then fully unset" state for nothing the operator asked for. func claimRawSetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value 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) } if !ValidExecDelivery(value) { return fmt.Errorf("config: invalid exec_delivery %q — want %q or \"\" (off/inherit)", value, ExecDeliveryFile) } key, label := Key(acct, app), "default" if envName != "" { if !ValidEnvironmentName(envName) { return fmt.Errorf("config: invalid environment name %q", envName) } key, label = EnvironmentKey(acct, app, envName), envName } m, etag, found, err := getForWriteAt(ctx, s, key, app) if err != nil { return err } if !found && value == "" { return fmt.Errorf("config: %s has no %s bundle — nothing to turn off", app, label) } m.ExecDelivery = value m.Schema, m.App = 1, app raw, err := json.MarshalIndent(m, "", " ") if err != nil { return err } if _, err := s.Put(ctx, key, raw, etag); err != nil { return err } // The opt-in flag changes what SyncExec delivers for this app's routes, // so it is a config change like any other and earns the same fast // convergence — see apply's comment for why the touch cannot fail this // call. The generation object is per-APP, not per-bundle // (generation.go), so an environment-scoped write signals through the // same key an app-level one does — which is why the fast loop needs no // new bundle awareness for this half. touchLogged(ctx, s, acct, app) return nil } func SetEnv(ctx context.Context, s release.Store, acct, app string, kv map[string]string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetEnv", func(claimContext context.Context) error { claimResult0 = claimRawSetEnv(claimContext, s, acct, app, kv) return claimResult0 }) return } func SetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetExecDelivery", func(claimContext context.Context) error { claimResult0 = claimRawSetExecDelivery(claimContext, s, acct, app, value) return claimResult0 }) return } func SetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetExecDeliveryAt", func(claimContext context.Context) error { claimResult0 = claimRawSetExecDeliveryAt(claimContext, s, acct, app, envName, value) return claimResult0 }) return } func SetSecret(ctx context.Context, s release.Store, acct, app string, sealed map[string]string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetSecret", func(claimContext context.Context) error { claimResult0 = claimRawSetSecret(claimContext, s, acct, app, sealed) return claimResult0 }) return } func Unset(ctx context.Context, s release.Store, acct, app string, keys []string) (claimResult0 int, claimResult1 error) { claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.Unset", func(claimContext context.Context) error { claimResult0, claimResult1 = claimRawUnset(claimContext, s, acct, app, keys) return claimResult1 }) return }