| 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. |
| 12 | package hostenv |
| 13 | |
| 14 | import ( |
| 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. |
| 27 | const Path = "/etc/carlos/host.env" |
| 28 | |
| 29 | // SharedScope is the scope of the values every region holds alike. |
| 30 | const 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. |
| 36 | var 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. |
| 70 | var 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. |
| 101 | func 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. |
| 106 | func 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. |
| 120 | var 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. |
| 134 | const ( |
| 135 | BoxReference = "CARLOS_BOX_REFERENCE" |
| 136 | GroupReference = "group" |
| 137 | ) |
| 138 | |
| 139 | // Params is a store of named, encrypted parameters. |
| 140 | type 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 | |
| 152 | var ( |
| 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 | |
| 165 | var ( |
| 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. |
| 173 | func 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. |
| 183 | func 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. |
| 188 | func 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. |
| 217 | func 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. |
| 228 | const 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. |
| 236 | func 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. |
| 263 | func 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. |
| 293 | func 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. |
| 307 | func 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. |
| 336 | type 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. |
| 351 | func 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. |
| 419 | func 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 | |
| 434 | func 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 | |