carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package hostenv is a box's /etc/carlos/host.env as a parameter layout
2// (region HA spec 2 §5 step 2, §4). Every value a hosting box reads from
3// host.env is one parameter, named for its variable, under
4// /carlos/<deployment>/<region>/, or under /carlos/<deployment>/shared/
5// for the values every region of the deployment holds alike. A box's
6// start-up assembles host.env from the two; an operator seeds them from a
7// box's existing file.
8//
9// Where the parameters live is an adapter's business (awsssm.go, AWS SSM
10// SecureStrings). This file names no provider (§25): host.env is a file,
11// and Params is all the core asks of a parameter store.
12package hostenv
13
14import (
15 "bufio"
16 "bytes"
17 "context"
18 "errors"
19 "fmt"
20 "regexp"
21 "slices"
22 "sort"
23 "strings"
24)
25
26// Path is the file a box's units read their environment from.
27const Path = "/etc/carlos/host.env"
28
29// SharedScope is the scope of the values every region holds alike.
30const SharedScope = "shared"
31
32// Required are the variables a hosting box cannot run without (§5 step 2):
33// the buckets and their region, the release and secrets keys, the box's
34// region and channel, the alert topic, and the claim and region HA
35// switches. Assembling host.env refuses without any of them.
36var Required = []string{
37 "CARLOS_ALERT_SNS_TOPIC_ARN",
38 "CARLOS_DEPLOYMENT_BUCKET",
39 "CARLOS_HIBERNATE_BUCKET",
40 "CARLOS_HIBERNATE_REGION",
41 "CARLOS_HOST_CLAIM",
42 "CARLOS_HOST_CLAIM_ORIGINS",
43 "CARLOS_REGION",
44 "CARLOS_REGION_HA",
45 "CARLOS_RELEASE_PUBKEY",
46 "CARLOS_SECRETS_KEY",
47 "CARLOS_SYSTEM_CHANNEL",
48}
49
50// Optional are the other variables a box its region's group launched may
51// carry, from the agent's config surface (README's host.env list and the
52// variables the agent reads). Together with Required they are the
53// allowlist: any other name, under either scope, is refused by name,
54// never by value, by write (Assemble) and by seed. The list leaves out
55// the keys that select a box's authority (a fleet's or tunnel's identity,
56// an address override, a pool, the data, opt and deployment directories,
57// the relay's settings) and every token or API credential, except the
58// deployment's own required secrets, CARLOS_SECRETS_KEY and
59// CARLOS_RELEASE_PUBKEY. CARLOS_ALERT_WEBHOOK is left out on the same
60// rule: a webhook URL can carry a credential. So are the status
61// destination overrides, CARLOS_STATUS_BUCKET, CARLOS_STATUS_PREFIX and
62// CARLOS_STATUS_REGION: a group box reports to the deployment bucket,
63// unprefixed, where the console reads its guard-ready report, while it is
64// joining and once it is hosting. An endpoint override such as
65// CARLOS_S3_ENDPOINT stays, because it selects no box authority and only
66// operators can write the parameter paths. CARLOS_REGION and the group
67// marker, which do select, are cross-checked against the signed roster
68// where the agent uses them (internal/host's mode selection). Adding a
69// variable is a code change, reviewed.
70var Optional = []string{
71 "CARLOS_ACME_DELEGATION_ZONE",
72 "CARLOS_ACME_DIRECTORY",
73 "CARLOS_ALERT_CPU_PCT",
74 "CARLOS_ALERT_DISK_PCT",
75 "CARLOS_ALERT_MEM_PCT",
76 "CARLOS_AWS_REGION",
77 "CARLOS_BACKHAUL_MAX_INFLIGHT",
78 "CARLOS_DNS_PROVIDER",
79 "CARLOS_EDGE_GZIP",
80 "CARLOS_HIBERNATE_IDLE",
81 "CARLOS_HIBERNATE_PREFIX",
82 "CARLOS_HOST_CLAIM_RETIRED_LOCATIONS",
83 "CARLOS_HOST_METRICS",
84 "CARLOS_LAYOUT_DEFAULT",
85 "CARLOS_MOVES_PHASE1",
86 "CARLOS_MOVE_READY_DELTA_BYTES",
87 "CARLOS_PLAN_LIMITS",
88 "CARLOS_REQUIRE_POINTER_DIGEST",
89 "CARLOS_S3_ENDPOINT",
90 "CARLOS_S3_QUIRKS",
91 "CARLOS_STEERING_ZONE_ID",
92 "CARLOS_SWAP",
93 "CARLOS_UNIT_SETTLE",
94 "CARLOS_VERSIONS_GRACE",
95 "CARLOS_WILDCARD_DOMAINS",
96 "STATUS_BOX_REGION",
97}
98
99// Allowed reports whether name is a variable host.env may carry from the
100// parameters: Required or Optional.
101func Allowed(name string) bool {
102 return slices.Contains(Required, name) || slices.Contains(Optional, name)
103}
104
105// notAllowed lists, sorted, the names in env that are not Allowed.
106func notAllowed(env map[string]string) []string {
107 var out []string
108 for k := range env {
109 if k != BoxReference && !Allowed(k) {
110 out = append(out, k)
111 }
112 }
113 sort.Strings(out)
114 return out
115}
116
117// SharedKeys are the variables that are the same in every region of a
118// deployment, so they are kept once, under the shared scope. Everything
119// else is the region's own.
120var SharedKeys = []string{
121 "CARLOS_DEPLOYMENT_BUCKET",
122 "CARLOS_HOST_CLAIM",
123 "CARLOS_HOST_CLAIM_ORIGINS",
124 "CARLOS_RELEASE_PUBKEY",
125 "CARLOS_SECRETS_KEY",
126}
127
128// BoxReference and GroupReference are the one line host.env carries that
129// is not a parameter. write runs only in the start-up of a box its
130// region's group launched, so a host.env it wrote marks such a box, and
131// the box takes the reference its account and instance derive
132// (hostactors.GroupBoxReference) instead of a status label. Every other
133// box's host.env lacks it and keeps its label.
134const (
135 BoxReference = "CARLOS_BOX_REFERENCE"
136 GroupReference = "group"
137)
138
139// Params is a store of named, encrypted parameters.
140type Params interface {
141 // ByPath returns every parameter directly under prefix, keyed by its
142 // name after the prefix, decrypted. A read that fails or stops part
143 // way is an error, never a partial map.
144 ByPath(ctx context.Context, prefix string) (map[string]string, error)
145 // Set writes one parameter, encrypted, replacing any value it had.
146 Set(ctx context.Context, name, value string) error
147 // SetNew writes one parameter, encrypted, only if it does not exist:
148 // ErrExists when it does, and the value is not changed.
149 SetNew(ctx context.Context, name, value string) error
150}
151
152var (
153 // ErrMissing is a required variable with no value.
154 ErrMissing = errors.New("required values are missing")
155 // ErrConflict is a variable held both by the region and shared, or a
156 // shared value seeding would change.
157 ErrConflict = errors.New("values conflict")
158 // ErrValue is a name or value host.env cannot hold safely.
159 ErrValue = errors.New("a name or value host.env cannot hold")
160 // ErrExists is SetNew's answer for a parameter that is already set:
161 // another seed wrote it first.
162 ErrExists = errors.New("already set by another seed")
163)
164
165var (
166 scopeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
167 keyName = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
168 safeValue = regexp.MustCompile(`^[A-Za-z0-9_./:,@+=%-]*$`)
169)
170
171// Prefix is the parameter path of one scope: a region's placement code or
172// SharedScope. Both names are refused unless they are lower-case labels.
173func Prefix(deployment, scope string) (string, error) {
174 if !scopeName.MatchString(deployment) || !scopeName.MatchString(scope) {
175 return "", fmt.Errorf("%w: %q and %q must be lower-case labels", ErrValue, deployment, scope)
176 }
177 return "/carlos/" + deployment + "/" + scope + "/", nil
178}
179
180// ValidKey reports whether name is a variable host.env carries. Other
181// parameters under a scope (the bootstrap object's sha256, for one) are
182// not variables and are skipped.
183func ValidKey(name string) bool { return keyName.MatchString(name) }
184
185// Assemble merges the shared and the region's values. A variable in both
186// is a conflict, never resolved by precedence: one of the two is wrong.
187// Every Required variable must have a value.
188func Assemble(shared, regional map[string]string) (map[string]string, error) {
189 env := map[string]string{}
190 var both []string
191 for _, src := range []map[string]string{shared, regional} {
192 for k, v := range src {
193 if !ValidKey(k) || k == BoxReference {
194 continue
195 }
196 if _, dup := env[k]; dup {
197 both = append(both, k)
198 continue
199 }
200 env[k] = v
201 }
202 }
203 if len(both) > 0 {
204 sort.Strings(both)
205 return nil, fmt.Errorf("%w: %s set both for the region and shared", ErrConflict, strings.Join(both, ", "))
206 }
207 if bad := notAllowed(env); len(bad) > 0 {
208 return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
209 }
210 if missing := Missing(env); len(missing) > 0 {
211 return nil, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", "))
212 }
213 return env, nil
214}
215
216// Missing lists the Required variables env has no value for, sorted.
217func Missing(env map[string]string) []string {
218 var out []string
219 for _, k := range Required {
220 if strings.TrimSpace(env[k]) == "" {
221 out = append(out, k)
222 }
223 }
224 return out
225}
226
227// renderedHeader is the first line of a host.env Render writes.
228const renderedHeader = "# Written by carlos system host-env write from the deployment's parameters. Do not edit by hand."
229
230// Render is host.env's bytes: one KEY=value line per variable, sorted.
231// Values are written bare, so the file means the same to systemd's
232// EnvironmentFile and to a shell that sources it. A value outside the safe
233// set (letters, digits and _ . / : , @ + = % -) is refused rather than
234// quoted; the error names the variables, never their values, which may be
235// secrets.
236func Render(env map[string]string) ([]byte, error) {
237 keys := make([]string, 0, len(env))
238 var bad []string
239 for k, v := range env {
240 if !ValidKey(k) || !safeValue.MatchString(v) {
241 bad = append(bad, k)
242 continue
243 }
244 keys = append(keys, k)
245 }
246 if len(bad) > 0 {
247 sort.Strings(bad)
248 return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
249 }
250 sort.Strings(keys)
251 var b bytes.Buffer
252 b.WriteString(renderedHeader + "\n")
253 for _, k := range keys {
254 b.WriteString(k + "=" + env[k] + "\n")
255 }
256 return b.Bytes(), nil
257}
258
259// Parse reads an existing host.env: KEY=value lines, with blank lines,
260// comments and a leading "export " ignored, and one layer of matching
261// quotes removed from a value. A variable set twice, or a line that is
262// not an assignment, is refused.
263func Parse(body []byte) (map[string]string, error) {
264 env := map[string]string{}
265 sc := bufio.NewScanner(bytes.NewReader(body))
266 for n := 1; sc.Scan(); n++ {
267 line := strings.TrimSpace(sc.Text())
268 if line == "" || strings.HasPrefix(line, "#") {
269 continue
270 }
271 line = strings.TrimPrefix(line, "export ")
272 k, v, ok := strings.Cut(line, "=")
273 k = strings.TrimSpace(k)
274 if !ok || !ValidKey(k) {
275 return nil, fmt.Errorf("%w: line %d is not KEY=value", ErrValue, n)
276 }
277 v = strings.TrimSpace(v)
278 if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
279 v = v[1 : len(v)-1]
280 }
281 if _, dup := env[k]; dup {
282 return nil, fmt.Errorf("%w: %s is set twice", ErrValue, k)
283 }
284 env[k] = v
285 }
286 if err := sc.Err(); err != nil {
287 return nil, err
288 }
289 return env, nil
290}
291
292// Split divides env into the shared variables and the region's own.
293func Split(env map[string]string) (shared, regional map[string]string) {
294 shared, regional = map[string]string{}, map[string]string{}
295 for k, v := range env {
296 if slices.Contains(SharedKeys, k) {
297 shared[k] = v
298 } else {
299 regional[k] = v
300 }
301 }
302 return shared, regional
303}
304
305// Fetch assembles host.env for one region of a deployment from p, for a
306// box the region's group launched: it adds BoxReference.
307func Fetch(ctx context.Context, p Params, deployment, region string) ([]byte, error) {
308 if region == SharedScope {
309 return nil, fmt.Errorf("%w: %q is not a region", ErrValue, region)
310 }
311 sharedPrefix, err := Prefix(deployment, SharedScope)
312 if err != nil {
313 return nil, err
314 }
315 regionPrefix, err := Prefix(deployment, region)
316 if err != nil {
317 return nil, err
318 }
319 shared, err := p.ByPath(ctx, sharedPrefix)
320 if err != nil {
321 return nil, fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
322 }
323 regional, err := p.ByPath(ctx, regionPrefix)
324 if err != nil {
325 return nil, fmt.Errorf("parameters under %s: %w", regionPrefix, err)
326 }
327 env, err := Assemble(shared, regional)
328 if err != nil {
329 return nil, err
330 }
331 env[BoxReference] = GroupReference
332 return Render(env)
333}
334
335// Seeded is what Seed wrote: the parameters under each prefix.
336type Seeded struct {
337 SharedPrefix, RegionPrefix string
338 Shared, Regional int
339}
340
341// Seed writes env, a box's existing host.env, into the layout for region.
342// The region's variables are written over whatever is there. A shared
343// variable is written only when it is not yet set, and create-only
344// (SetNew): one region's file never changes a value every region reads.
345// A shared value that differs is a conflict, and nothing at all is
346// written. Two seeds racing for the same shared variable cannot both
347// win: the one SetNew refuses reads the value back and goes on only if
348// it is the value it would have written. env must hold every Required
349// variable, in a form Render accepts. BoxReference is never seeded: it
350// is write's.
351func Seed(ctx context.Context, p Params, deployment, region string, env map[string]string) (Seeded, error) {
352 if region == SharedScope {
353 return Seeded{}, fmt.Errorf("%w: %q is not a region", ErrValue, region)
354 }
355 sharedPrefix, err := Prefix(deployment, SharedScope)
356 if err != nil {
357 return Seeded{}, err
358 }
359 regionPrefix, err := Prefix(deployment, region)
360 if err != nil {
361 return Seeded{}, err
362 }
363 if missing := Missing(env); len(missing) > 0 {
364 return Seeded{}, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", "))
365 }
366 if bad := notAllowed(env); len(bad) > 0 {
367 return Seeded{}, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
368 }
369 if _, err := Render(env); err != nil {
370 return Seeded{}, err
371 }
372 shared, regional := Split(env)
373 delete(regional, BoxReference)
374 existing, err := p.ByPath(ctx, sharedPrefix)
375 if err != nil {
376 return Seeded{}, fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
377 }
378 var differ []string
379 for k, v := range shared {
380 if have, ok := existing[k]; ok && have != v {
381 differ = append(differ, k)
382 }
383 }
384 if len(differ) > 0 {
385 sort.Strings(differ)
386 return Seeded{}, fmt.Errorf("%w: the shared %s already differ", ErrConflict, strings.Join(differ, ", "))
387 }
388 out := Seeded{SharedPrefix: sharedPrefix, RegionPrefix: regionPrefix}
389 for _, k := range sortedKeys(shared) {
390 if _, ok := existing[k]; ok {
391 continue
392 }
393 err := p.SetNew(ctx, sharedPrefix+k, shared[k])
394 if errors.Is(err, ErrExists) {
395 // Another seed set it since the read above: go on only if it
396 // set what this one would have.
397 if err := sameShared(ctx, p, sharedPrefix, k, shared[k]); err != nil {
398 return out, err
399 }
400 continue
401 }
402 if err != nil {
403 return out, fmt.Errorf("%s%s: %w", sharedPrefix, k, err)
404 }
405 out.Shared++
406 }
407 for _, k := range sortedKeys(regional) {
408 if err := p.Set(ctx, regionPrefix+k, regional[k]); err != nil {
409 return out, fmt.Errorf("%s%s: %w", regionPrefix, k, err)
410 }
411 out.Regional++
412 }
413 return out, nil
414}
415
416// sameShared reads the shared variable k back after SetNew found it set,
417// and refuses unless it holds want. The error names the variable, never
418// either value.
419func sameShared(ctx context.Context, p Params, sharedPrefix, k, want string) error {
420 now, err := p.ByPath(ctx, sharedPrefix)
421 if err != nil {
422 return fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
423 }
424 have, ok := now[k]
425 if !ok {
426 return fmt.Errorf("%s%s: %w", sharedPrefix, k, ErrExists)
427 }
428 if have != want {
429 return fmt.Errorf("%w: the shared %s already differ", ErrConflict, k)
430 }
431 return nil
432}
433
434func sortedKeys(m map[string]string) []string {
435 keys := make([]string, 0, len(m))
436 for k := range m {
437 keys = append(keys, k)
438 }
439 sort.Strings(keys)
440 return keys
441}
442