package appconfig import ( "context" "crypto/ecdh" "fmt" "github.com/carlosframework/platform/internal/release" ) // The admission question (amadan #213): can THIS box provide this instance's // config? A box that cannot must not take the instance's lease or mint its // record, because the instance would start without its secrets. That is what // a keyless UK box did to kpmg for 1h54m on 2026-10-01: the config sync loop // is gated on CARLOS_SECRETS_KEY, instanceEnv treats a missing // .config.env as optional, and the instance booted with nothing. // // The answer is the same question the sync pass asks, asked before the side // effect instead of after: resolve the merged manifest the box would render // for this host (default < bound environment < instance overlay), and if it // carries sealed secrets, prove this box's key opens them. // KeyState says what the box found when it read CARLOS_SECRETS_KEY. The // private key itself travels separately; this is only why there is none. type KeyState int const ( // KeyOK: the key parsed. The *ecdh.PrivateKey argument is non-nil. KeyOK KeyState = iota // KeyAbsent: CARLOS_SECRETS_KEY is unset or blank. KeyAbsent // KeyUnparseable: it is set but is not a P-256 private key. KeyUnparseable ) // The closed set of refusal reasons. They are published in the box's status // snapshot, so they are words, never text built from a store or crypto error. const ( ReasonNoKey = "no_key" ReasonKeyUnparseable = "key_unparseable" ReasonCannotOpen = "cannot_open" ReasonStoreError = "store_error" // ReasonNoSource: the box has no deployment store configured, so it // cannot read the instance's config to know whether any is sealed. It is // a refusal unless the operator has said, explicitly, that this // deployment has no sealed config (the host's CARLOS_NO_SEALED_CONFIG). ReasonNoSource = "no_config_source" ) // ConfigRefusal is CanProvideConfig's refusal. Reason is one of the Reason* // words above and is the only part safe to publish; Err is the operator-log // detail (never a secret: crypto errors name no plaintext and no envelope) // and is what errors.Is/As see through. type ConfigRefusal struct { Reason string Err error } func (r *ConfigRefusal) Error() string { if r.Err == nil { return "cannot provide this instance's config: " + r.Reason } return "cannot provide this instance's config: " + r.Reason + ": " + r.Err.Error() } func (r *ConfigRefusal) Unwrap() error { return r.Err } // CanProvideConfig reports nil when this box can provide the config of the // instance at host, and a *ConfigRefusal when it cannot or cannot tell. // // - Nothing sealed in the merged view (no manifest at all, or only plain // vars): nothing needs a key, so nil. A deployment with no sealed config // anywhere is unaffected, key or no key. // - Something sealed: the box needs a parseable key, and the merged view // must RENDER, which is what SyncExec does to produce .config.env // and what instanceEnv then reads: every envelope opens under the route's // own binding and every value is writable. Sealing records no recipient // (an envelope is ephemeral point, nonce, ciphertext), so there is no // fingerprint to compare; rendering is the proof, and the only one that // matches what delivery needs. // - Any read or parse failure refuses (ReasonStoreError). An error is never // "no sealed config". // // Complete renderability, not "one envelope opens": SyncExec refuses to write // the WHOLE file when any one decrypt fails, and instanceEnv treats a missing // file as optional, so a half-openable manifest on a box with no rendered // file starts the instance with none of its sealed config. // // There is no last-known-good fallback, deliberately. A rendered // .config.env left on disk from an earlier pass proves nothing about // its VALUES: names alone pass for "A=old" or "A=" or credentials rendered for // another environment, and sync leaves such a file untouched when it cannot // render. A fresh acquisition or respawn starts only if the whole merged // config renders now. The fix for a mixed-key or partly corrupt manifest is // to give the box the key or re-seal the config. (Continuing a process that is // already running is a different policy and not this function's.) func CanProvideConfig(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, ks KeyState, acct, app, envName, host string) error { var ( m *Manifest err error ) if ValidInstanceHost(host) { m, _, err = LoadMergedHost(ctx, s, acct, app, envName, host) } else { // A host that cannot name an overlay key has no overlay (the sweeps // treat it the same way); the app-level layers still apply. m, err = LoadMerged(ctx, s, acct, app, envName) } if err != nil { return &ConfigRefusal{Reason: ReasonStoreError, Err: err} } sealed := 0 for _, v := range m.Vars { if v.Kind == "secret" { sealed++ } } if sealed == 0 { return nil } if priv == nil || ks != KeyOK { if ks == KeyUnparseable { return &ConfigRefusal{Reason: ReasonKeyUnparseable} } return &ConfigRefusal{Reason: ReasonNoKey} } bind := Binding{Account: acct, App: app} if _, err := RenderEnv(m, priv, bind); err != nil { return &ConfigRefusal{Reason: ReasonCannotOpen, Err: fmt.Errorf("this box's key cannot render the config: %w", err)} } return nil }