| 1 | // CAS-guarded reads and writes of a Manifest, on top of release.Store — the |
| 2 | // same read-modify-write-with-If-Match discipline release.Promote already |
| 3 | // uses for channel pointers, without that function's signing (config |
| 4 | // manifests aren't a fleet trust boundary the way a channel pointer is: |
| 5 | // nothing executes because a manifest says so). |
| 6 | package appconfig |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "os" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | // Get fetches app's config manifest, an empty one (not an error) if it has |
| 19 | // never been configured — a valid state for `list`, which has nothing to |
| 20 | // print either way. |
| 21 | func Get(ctx context.Context, s release.Store, acct, app string) (*Manifest, error) { |
| 22 | m, found, err := getIfExists(ctx, s, acct, app) |
| 23 | if err != nil { |
| 24 | return nil, err |
| 25 | } |
| 26 | if !found { |
| 27 | return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil |
| 28 | } |
| 29 | return m, nil |
| 30 | } |
| 31 | |
| 32 | // getIfExists is Get plus the one bit Get deliberately folds away: whether a |
| 33 | // manifest object exists in the bucket at all. An empty manifest is |
| 34 | // ambiguous on its own — "never configured" and "configured, then fully |
| 35 | // unset" both read as zero vars — and Sync must tell them apart: the first |
| 36 | // has to leave a host's hand-staged env file untouched, the second has to |
| 37 | // converge it to empty. found==false means no object, and the returned |
| 38 | // manifest is nil. |
| 39 | func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manifest, bool, error) { |
| 40 | if !release.ValidAccount(acct) { |
| 41 | return nil, false, fmt.Errorf("config: invalid account %q", acct) |
| 42 | } |
| 43 | if !release.ValidApp(app) { |
| 44 | return nil, false, fmt.Errorf("config: invalid app %q", app) |
| 45 | } |
| 46 | return getIfExistsAt(ctx, s, Key(acct, app)) |
| 47 | } |
| 48 | |
| 49 | // getIfExistsAt is getIfExists generalized to any manifest key — Key(acct, |
| 50 | // app) for the default bundle, EnvironmentKey(acct, app, name) for a named |
| 51 | // one (see environments.go), InstanceConfigKey(acct, app, host) for an |
| 52 | // instance overlay (instances.go). Same shape either way: all three are the |
| 53 | // identical manifest format at sibling bucket paths, so one read path |
| 54 | // serves them. |
| 55 | func getIfExistsAt(ctx context.Context, s release.Store, key string) (*Manifest, bool, error) { |
| 56 | m, _, found, err := getIfExistsAtETag(ctx, s, key) |
| 57 | return m, found, err |
| 58 | } |
| 59 | |
| 60 | // getIfExistsAtETag is getIfExistsAt plus the object's etag, which the |
| 61 | // instance layer's reader needs and the other two bundles' readers do not: |
| 62 | // the delivery ack names the etag of the overlay the agent rendered |
| 63 | // (senders design §6.5), and an etag dropped here could not be recovered |
| 64 | // later without a second read racing the first. |
| 65 | func getIfExistsAtETag(ctx context.Context, s release.Store, key string) (*Manifest, string, bool, error) { |
| 66 | raw, etag, err := s.Get(ctx, key) |
| 67 | if err != nil { |
| 68 | if errors.Is(err, os.ErrNotExist) { |
| 69 | return nil, "", false, nil |
| 70 | } |
| 71 | return nil, "", false, err |
| 72 | } |
| 73 | var m Manifest |
| 74 | if err := json.Unmarshal(raw, &m); err != nil { |
| 75 | return nil, "", false, fmt.Errorf("config %s: %w", key, err) |
| 76 | } |
| 77 | // json.Unmarshal has already dropped any fields this binary doesn't |
| 78 | // know about, so acting on a newer-schema manifest would silently |
| 79 | // discard whatever those fields meant. Refuse instead. |
| 80 | if m.Schema > 1 { |
| 81 | return nil, "", false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema) |
| 82 | } |
| 83 | return &m, etag, true, nil |
| 84 | } |
| 85 | |
| 86 | // getForWriteAt is Get plus the two bits a caller needs to write back: the |
| 87 | // etag (a not-found manifest reads as empty with etag "", a create-only Put) |
| 88 | // and whether the object exists at all. found is NOT inferable from the etag |
| 89 | // — a store is free to hand back an empty etag for an object that exists — |
| 90 | // and applyAt's refusal to create a bundle on an unset-only call turns on it. |
| 91 | // Generalized to any manifest key; see getIfExistsAt's doc comment for why |
| 92 | // one path serves both the default bundle and a named environment. |
| 93 | func getForWriteAt(ctx context.Context, s release.Store, key, app string) (Manifest, string, bool, error) { |
| 94 | raw, etag, err := s.Get(ctx, key) |
| 95 | switch { |
| 96 | case err == nil: |
| 97 | var m Manifest |
| 98 | if uerr := json.Unmarshal(raw, &m); uerr != nil { |
| 99 | return Manifest{}, "", false, fmt.Errorf("config %s: %w", key, uerr) |
| 100 | } |
| 101 | // Same newer-schema refusal as getIfExists — writing back through |
| 102 | // this path would downgrade the object to schema 1 minus the fields |
| 103 | // unmarshal already dropped. |
| 104 | if m.Schema > 1 { |
| 105 | return Manifest{}, "", false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema) |
| 106 | } |
| 107 | return m, etag, true, nil |
| 108 | case errors.Is(err, os.ErrNotExist): |
| 109 | return Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", false, nil |
| 110 | default: |
| 111 | return Manifest{}, "", false, err |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // apply reads, applies sets and unsets, and writes back via CAS, reporting |
| 116 | // how many of unsets were actually present. A racing concurrent write |
| 117 | // surfaces as release.ErrCASConflict — callers report it and let the operator |
| 118 | // re-run; there is no automatic retry, matching release.Promote's own |
| 119 | // behavior. |
| 120 | // |
| 121 | // On success it also touches the app's generation object (generation.go), so |
| 122 | // the change is visible to the agent's fast loop within seconds rather than |
| 123 | // at the next hourly sweep. Best-effort by construction: the manifest write |
| 124 | // has already landed by then, so a failed touch is logged, never returned |
| 125 | // (see touchLogged). |
| 126 | func apply(ctx context.Context, s release.Store, acct, app, bundle string, sets map[string]Var, unsets []string) (int, error) { |
| 127 | if !release.ValidAccount(acct) { |
| 128 | return 0, fmt.Errorf("config: invalid account %q", acct) |
| 129 | } |
| 130 | if !release.ValidApp(app) { |
| 131 | return 0, fmt.Errorf("config: invalid app %q", app) |
| 132 | } |
| 133 | removed, _, err := applyAt(ctx, s, Key(acct, app), app, bundle, sets, unsets) |
| 134 | if err != nil { |
| 135 | return 0, err |
| 136 | } |
| 137 | touchLogged(ctx, s, acct, app) |
| 138 | return removed, nil |
| 139 | } |
| 140 | |
| 141 | // applyAt is apply generalized to any manifest key — see getIfExistsAt's doc |
| 142 | // comment for why one path serves both the default bundle and a named |
| 143 | // environment. Callers validate acct/app/environment-name themselves before |
| 144 | // reaching here, same division as apply does for Key(acct, app). bundle is |
| 145 | // how to name this object to a human ("default", or the environment name); |
| 146 | // it never affects which key is written. |
| 147 | // |
| 148 | // An unset-only call against an object that does not exist is REFUSED, not |
| 149 | // silently turned into a create (2026-08-02 review, CRITICAL 2). Creating it |
| 150 | // would mint an empty manifest, and an empty manifest that EXISTS means |
| 151 | // "configured, then fully unset" by this package's own contract — which is |
| 152 | // exactly the state Sync converges every one of that app's route env files |
| 153 | // to empty for, as root, restarting each unit. It also minted phantom |
| 154 | // environment bundles that then satisfied `carlos route -environment`'s |
| 155 | // existence rail. Sets still create, which is how a bundle is meant to come |
| 156 | // into being. |
| 157 | func applyAt(ctx context.Context, s release.Store, key, app, bundle string, sets map[string]Var, unsets []string) (int, string, error) { |
| 158 | for k, v := range sets { |
| 159 | if !ValidVarName(k) { |
| 160 | return 0, "", fmt.Errorf("config: invalid var name %q", k) |
| 161 | } |
| 162 | // Env values are validated here, while the operator can still see |
| 163 | // the plaintext — a bad value that only failed at render time would |
| 164 | // stall the whole app's config convergence on every host, hours |
| 165 | // later and out of sight. Secret values get the same check in the |
| 166 | // CLI, before sealing; by this point they're opaque envelopes. |
| 167 | if v.Kind == "env" { |
| 168 | if err := ValidVarValue(v.Value); err != nil { |
| 169 | return 0, "", fmt.Errorf("config: var %s: %w", k, err) |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | m, etag, found, err := getForWriteAt(ctx, s, key, app) |
| 174 | if err != nil { |
| 175 | return 0, "", err |
| 176 | } |
| 177 | if !found && len(sets) == 0 { |
| 178 | return 0, "", fmt.Errorf("config: %s has no %s bundle — nothing to unset", app, bundle) |
| 179 | } |
| 180 | if m.Vars == nil { |
| 181 | m.Vars = map[string]Var{} |
| 182 | } |
| 183 | removed := 0 |
| 184 | for _, k := range unsets { |
| 185 | if _, ok := m.Vars[k]; ok { |
| 186 | removed++ |
| 187 | } |
| 188 | delete(m.Vars, k) |
| 189 | } |
| 190 | for k, v := range sets { |
| 191 | m.Vars[k] = v |
| 192 | } |
| 193 | m.Schema, m.App = 1, app |
| 194 | raw, err := json.MarshalIndent(m, "", " ") |
| 195 | if err != nil { |
| 196 | return 0, "", err |
| 197 | } |
| 198 | // The new etag is returned because the instance layer's writer (design |
| 199 | // §6.5) has to hand it to whoever will later compare it with an agent's |
| 200 | // delivery ack. The other two bundles' callers ignore it. |
| 201 | newETag, err := s.Put(ctx, key, raw, etag) |
| 202 | if err != nil { |
| 203 | return 0, "", err |
| 204 | } |
| 205 | return removed, newETag, nil |
| 206 | } |
| 207 | |
| 208 | // DefaultBundle is how the default bundle (config.json) is named to a human |
| 209 | // in messages — the same vocabulary ValidEnvironmentName reserves and the CLI |
| 210 | // prints, kept in one place so an error from this package and a `list` header |
| 211 | // can never drift apart. |
| 212 | const DefaultBundle = "default" |
| 213 | |
| 214 | // SetEnv writes one or more plaintext config vars. |
| 215 | func claimRawSetEnv(ctx context.Context, s release.Store, acct, app string, kv map[string]string) error { |
| 216 | sets := make(map[string]Var, len(kv)) |
| 217 | for k, v := range kv { |
| 218 | sets[k] = Var{Kind: "env", Value: v} |
| 219 | } |
| 220 | _, err := apply(ctx, s, acct, app, DefaultBundle, sets, nil) |
| 221 | return err |
| 222 | } |
| 223 | |
| 224 | // SetSecret writes one or more secrets. sealed values must already be |
| 225 | // Seal-produced envelopes — this function stores them opaquely and never |
| 226 | // sees plaintext. |
| 227 | func claimRawSetSecret(ctx context.Context, s release.Store, acct, app string, sealed map[string]string) error { |
| 228 | sets := make(map[string]Var, len(sealed)) |
| 229 | for k, v := range sealed { |
| 230 | sets[k] = Var{Kind: "secret", Envelope: v} |
| 231 | } |
| 232 | _, err := apply(ctx, s, acct, app, DefaultBundle, sets, nil) |
| 233 | return err |
| 234 | } |
| 235 | |
| 236 | // Unset removes vars (env or secret) by key from the default bundle, |
| 237 | // returning how many of keys were actually there. Unsetting a key that isn't |
| 238 | // set is a no-op success, matching heroku config:unset's idempotence — |
| 239 | // unsetting from a bundle that does not EXIST is not (see applyAt). |
| 240 | func claimRawUnset(ctx context.Context, s release.Store, acct, app string, keys []string) (int, error) { |
| 241 | return apply(ctx, s, acct, app, DefaultBundle, nil, keys) |
| 242 | } |
| 243 | |
| 244 | // SetExecDelivery writes (`file`) or clears (`""`) the DEFAULT bundle's |
| 245 | // exec_delivery field — issue #18's TCP-addr exec route opt-in (see |
| 246 | // Manifest.ExecDelivery). The app-level door, unchanged in behaviour and |
| 247 | // signature; SetExecDeliveryAt is the general one. |
| 248 | func claimRawSetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) error { |
| 249 | return SetExecDeliveryAt(ctx, s, acct, app, "", value) |
| 250 | } |
| 251 | |
| 252 | // SetExecDeliveryAt writes or clears exec_delivery in ONE bundle: the |
| 253 | // default bundle (envName == "") or a named environment's, via the same |
| 254 | // CAS read-modify-write path SetEnv/SetSecret/Unset use. value must be |
| 255 | // ValidExecDelivery; anything else is refused here, before it ever reaches |
| 256 | // the bucket, mirroring applyAt's var-name/var-value validation. |
| 257 | // |
| 258 | // Environment scope is issue #195 (ruled 2026-08-16). The app-level flag |
| 259 | // answered "does this APP get files", so opting one relay route in wrote a |
| 260 | // file for every router route of the same app too — a fleet-wide switch |
| 261 | // thrown to deliver one service's credential. The flag now lives wherever |
| 262 | // the route's config comes from, and clearing it in a named bundle means |
| 263 | // INHERIT the app default, never "off": a bundle that says nothing has not |
| 264 | // asked for delivery to stop. Route scope is still refused — see |
| 265 | // Manifest.ExecDelivery. |
| 266 | // |
| 267 | // Turning the field off against a bundle that does not exist is refused, |
| 268 | // not silently turned into a create — the same "unset-only against a |
| 269 | // nonexistent object" refusal applyAt documents, for the same reason: |
| 270 | // minting an empty bundle just to hold an absent flag would satisfy |
| 271 | // `carlos route -environment`'s existence rail and Sync's "configured, |
| 272 | // then fully unset" state for nothing the operator asked for. |
| 273 | func claimRawSetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value string) error { |
| 274 | if !release.ValidAccount(acct) { |
| 275 | return fmt.Errorf("config: invalid account %q", acct) |
| 276 | } |
| 277 | if !release.ValidApp(app) { |
| 278 | return fmt.Errorf("config: invalid app %q", app) |
| 279 | } |
| 280 | if !ValidExecDelivery(value) { |
| 281 | return fmt.Errorf("config: invalid exec_delivery %q — want %q or \"\" (off/inherit)", value, ExecDeliveryFile) |
| 282 | } |
| 283 | key, label := Key(acct, app), "default" |
| 284 | if envName != "" { |
| 285 | if !ValidEnvironmentName(envName) { |
| 286 | return fmt.Errorf("config: invalid environment name %q", envName) |
| 287 | } |
| 288 | key, label = EnvironmentKey(acct, app, envName), envName |
| 289 | } |
| 290 | m, etag, found, err := getForWriteAt(ctx, s, key, app) |
| 291 | if err != nil { |
| 292 | return err |
| 293 | } |
| 294 | if !found && value == "" { |
| 295 | return fmt.Errorf("config: %s has no %s bundle — nothing to turn off", app, label) |
| 296 | } |
| 297 | m.ExecDelivery = value |
| 298 | m.Schema, m.App = 1, app |
| 299 | raw, err := json.MarshalIndent(m, "", " ") |
| 300 | if err != nil { |
| 301 | return err |
| 302 | } |
| 303 | if _, err := s.Put(ctx, key, raw, etag); err != nil { |
| 304 | return err |
| 305 | } |
| 306 | // The opt-in flag changes what SyncExec delivers for this app's routes, |
| 307 | // so it is a config change like any other and earns the same fast |
| 308 | // convergence — see apply's comment for why the touch cannot fail this |
| 309 | // call. The generation object is per-APP, not per-bundle |
| 310 | // (generation.go), so an environment-scoped write signals through the |
| 311 | // same key an app-level one does — which is why the fast loop needs no |
| 312 | // new bundle awareness for this half. |
| 313 | touchLogged(ctx, s, acct, app) |
| 314 | return nil |
| 315 | } |
| 316 | func SetEnv(ctx context.Context, s release.Store, acct, app string, kv map[string]string) (claimResult0 error) { |
| 317 | claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetEnv", func(claimContext context.Context) error { |
| 318 | claimResult0 = claimRawSetEnv(claimContext, s, acct, app, kv) |
| 319 | return claimResult0 |
| 320 | }) |
| 321 | return |
| 322 | } |
| 323 | func SetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) (claimResult0 error) { |
| 324 | claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetExecDelivery", func(claimContext context.Context) error { |
| 325 | claimResult0 = claimRawSetExecDelivery(claimContext, s, acct, app, value) |
| 326 | return claimResult0 |
| 327 | }) |
| 328 | return |
| 329 | } |
| 330 | func SetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value string) (claimResult0 error) { |
| 331 | claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetExecDeliveryAt", func(claimContext context.Context) error { |
| 332 | claimResult0 = claimRawSetExecDeliveryAt(claimContext, s, acct, app, envName, value) |
| 333 | return claimResult0 |
| 334 | }) |
| 335 | return |
| 336 | } |
| 337 | func SetSecret(ctx context.Context, s release.Store, acct, app string, sealed map[string]string) (claimResult0 error) { |
| 338 | claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetSecret", func(claimContext context.Context) error { |
| 339 | claimResult0 = claimRawSetSecret(claimContext, s, acct, app, sealed) |
| 340 | return claimResult0 |
| 341 | }) |
| 342 | return |
| 343 | } |
| 344 | func Unset(ctx context.Context, s release.Store, acct, app string, keys []string) (claimResult0 int, claimResult1 error) { |
| 345 | claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.Unset", func(claimContext context.Context) error { |
| 346 | claimResult0, claimResult1 = claimRawUnset(claimContext, s, acct, app, keys) |
| 347 | return claimResult1 |
| 348 | }) |
| 349 | return |
| 350 | } |
| 351 | |