| 1 | package appconfig |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdh" |
| 6 | "fmt" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/release" |
| 9 | ) |
| 10 | |
| 11 | // The admission question (amadan #213): can THIS box provide this instance's |
| 12 | // config? A box that cannot must not take the instance's lease or mint its |
| 13 | // record, because the instance would start without its secrets. That is what |
| 14 | // a keyless UK box did to kpmg for 1h54m on 2026-10-01: the config sync loop |
| 15 | // is gated on CARLOS_SECRETS_KEY, instanceEnv treats a missing |
| 16 | // <host>.config.env as optional, and the instance booted with nothing. |
| 17 | // |
| 18 | // The answer is the same question the sync pass asks, asked before the side |
| 19 | // effect instead of after: resolve the merged manifest the box would render |
| 20 | // for this host (default < bound environment < instance overlay), and if it |
| 21 | // carries sealed secrets, prove this box's key opens them. |
| 22 | |
| 23 | // KeyState says what the box found when it read CARLOS_SECRETS_KEY. The |
| 24 | // private key itself travels separately; this is only why there is none. |
| 25 | type KeyState int |
| 26 | |
| 27 | const ( |
| 28 | // KeyOK: the key parsed. The *ecdh.PrivateKey argument is non-nil. |
| 29 | KeyOK KeyState = iota |
| 30 | // KeyAbsent: CARLOS_SECRETS_KEY is unset or blank. |
| 31 | KeyAbsent |
| 32 | // KeyUnparseable: it is set but is not a P-256 private key. |
| 33 | KeyUnparseable |
| 34 | ) |
| 35 | |
| 36 | // The closed set of refusal reasons. They are published in the box's status |
| 37 | // snapshot, so they are words, never text built from a store or crypto error. |
| 38 | const ( |
| 39 | ReasonNoKey = "no_key" |
| 40 | ReasonKeyUnparseable = "key_unparseable" |
| 41 | ReasonCannotOpen = "cannot_open" |
| 42 | ReasonStoreError = "store_error" |
| 43 | // ReasonNoSource: the box has no deployment store configured, so it |
| 44 | // cannot read the instance's config to know whether any is sealed. It is |
| 45 | // a refusal unless the operator has said, explicitly, that this |
| 46 | // deployment has no sealed config (the host's CARLOS_NO_SEALED_CONFIG). |
| 47 | ReasonNoSource = "no_config_source" |
| 48 | ) |
| 49 | |
| 50 | // ConfigRefusal is CanProvideConfig's refusal. Reason is one of the Reason* |
| 51 | // words above and is the only part safe to publish; Err is the operator-log |
| 52 | // detail (never a secret: crypto errors name no plaintext and no envelope) |
| 53 | // and is what errors.Is/As see through. |
| 54 | type ConfigRefusal struct { |
| 55 | Reason string |
| 56 | Err error |
| 57 | } |
| 58 | |
| 59 | func (r *ConfigRefusal) Error() string { |
| 60 | if r.Err == nil { |
| 61 | return "cannot provide this instance's config: " + r.Reason |
| 62 | } |
| 63 | return "cannot provide this instance's config: " + r.Reason + ": " + r.Err.Error() |
| 64 | } |
| 65 | |
| 66 | func (r *ConfigRefusal) Unwrap() error { return r.Err } |
| 67 | |
| 68 | // CanProvideConfig reports nil when this box can provide the config of the |
| 69 | // instance at host, and a *ConfigRefusal when it cannot or cannot tell. |
| 70 | // |
| 71 | // - Nothing sealed in the merged view (no manifest at all, or only plain |
| 72 | // vars): nothing needs a key, so nil. A deployment with no sealed config |
| 73 | // anywhere is unaffected, key or no key. |
| 74 | // - Something sealed: the box needs a parseable key, and the merged view |
| 75 | // must RENDER, which is what SyncExec does to produce <host>.config.env |
| 76 | // and what instanceEnv then reads: every envelope opens under the route's |
| 77 | // own binding and every value is writable. Sealing records no recipient |
| 78 | // (an envelope is ephemeral point, nonce, ciphertext), so there is no |
| 79 | // fingerprint to compare; rendering is the proof, and the only one that |
| 80 | // matches what delivery needs. |
| 81 | // - Any read or parse failure refuses (ReasonStoreError). An error is never |
| 82 | // "no sealed config". |
| 83 | // |
| 84 | // Complete renderability, not "one envelope opens": SyncExec refuses to write |
| 85 | // the WHOLE file when any one decrypt fails, and instanceEnv treats a missing |
| 86 | // file as optional, so a half-openable manifest on a box with no rendered |
| 87 | // file starts the instance with none of its sealed config. |
| 88 | // |
| 89 | // There is no last-known-good fallback, deliberately. A rendered |
| 90 | // <host>.config.env left on disk from an earlier pass proves nothing about |
| 91 | // its VALUES: names alone pass for "A=old" or "A=" or credentials rendered for |
| 92 | // another environment, and sync leaves such a file untouched when it cannot |
| 93 | // render. A fresh acquisition or respawn starts only if the whole merged |
| 94 | // config renders now. The fix for a mixed-key or partly corrupt manifest is |
| 95 | // to give the box the key or re-seal the config. (Continuing a process that is |
| 96 | // already running is a different policy and not this function's.) |
| 97 | func CanProvideConfig(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, ks KeyState, acct, app, envName, host string) error { |
| 98 | var ( |
| 99 | m *Manifest |
| 100 | err error |
| 101 | ) |
| 102 | if ValidInstanceHost(host) { |
| 103 | m, _, err = LoadMergedHost(ctx, s, acct, app, envName, host) |
| 104 | } else { |
| 105 | // A host that cannot name an overlay key has no overlay (the sweeps |
| 106 | // treat it the same way); the app-level layers still apply. |
| 107 | m, err = LoadMerged(ctx, s, acct, app, envName) |
| 108 | } |
| 109 | if err != nil { |
| 110 | return &ConfigRefusal{Reason: ReasonStoreError, Err: err} |
| 111 | } |
| 112 | sealed := 0 |
| 113 | for _, v := range m.Vars { |
| 114 | if v.Kind == "secret" { |
| 115 | sealed++ |
| 116 | } |
| 117 | } |
| 118 | if sealed == 0 { |
| 119 | return nil |
| 120 | } |
| 121 | if priv == nil || ks != KeyOK { |
| 122 | if ks == KeyUnparseable { |
| 123 | return &ConfigRefusal{Reason: ReasonKeyUnparseable} |
| 124 | } |
| 125 | return &ConfigRefusal{Reason: ReasonNoKey} |
| 126 | } |
| 127 | bind := Binding{Account: acct, App: app} |
| 128 | if _, err := RenderEnv(m, priv, bind); err != nil { |
| 129 | return &ConfigRefusal{Reason: ReasonCannotOpen, Err: fmt.Errorf("this box's key cannot render the config: %w", err)} |
| 130 | } |
| 131 | return nil |
| 132 | } |
| 133 | |