carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package appconfig
2
3import (
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.
25type KeyState int
26
27const (
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.
38const (
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.
54type ConfigRefusal struct {
55 Reason string
56 Err error
57}
58
59func (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
66func (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.)
97func 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