carlos / platform Public

region HA 2b: host.env from the deployment's parameters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev d7e47251c49aa771892689bfcacf1dd077043512 parent 84eda48
14 files changed, +1293 −1
  • approved_region_ha_2b_copy_test.go +1 −1
  • go.mod +1 −0
  • go.sum +2 −0
  • internal/hostenv/awsssm.go +98 −0
  • internal/hostenv/cli.go +115 −0
  • internal/hostenv/hostenv.go +437 −0
  • internal/hostenv/hostenv_test.go +365 −0
  • internal/hostenv/write_linux.go +108 −0
  • internal/hostenv/write_linux_test.go +116 −0
  • internal/hostenv/write_other.go +11 −0
  • internal/hostenv/write_other_test.go +15 −0
  • internal/provision/plan14_root_reads_test.go +11 −0
  • internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv +7 −0
  • region_ha_boundary_test.go +6 −0
diff --git a/approved_region_ha_2b_copy_test.go b/approved_region_ha_2b_copy_test.go
index 49cb88729..0c166df31 100644
--- a/approved_region_ha_2b_copy_test.go
+++ b/approved_region_ha_2b_copy_test.go
@@ -38,7 +38,7 @@ var regionHA2bCopyIDs = func() []string {
// regionHA2bCopyBuilt is every id whose text the code or docs carry. The
// task that writes a row's text adds its ids here in the same commit, once
// Paul has approved the rows.
-var regionHA2bCopyBuilt = []string{}
+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68"}
func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/go.mod b/go.mod
index 8498846ed..63a0676ea 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
+ github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0
github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
github.com/aws/smithy-go v1.28.1
github.com/benbjohnson/litestream v0.5.14
diff --git a/go.sum b/go.sum
index f81bc083a..8633e5c02 100644
--- a/go.sum
+++ b/go.sum
@@ -68,6 +68,8 @@ github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sns v1.43.0 h1:VPYjwn0BoX34hb44OT8T+Ikgn4NzsN7fHetaHaevsDc=
github.com/aws/aws-sdk-go-v2/service/sns v1.43.0/go.mod h1:I1vnLPvvi9KBqxddu8nJ4vktoPJvaIG05UmjBD9sqm8=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0 h1:11o/Z2uQatZTTWrkwN4MMPjhcVGnVQtLra5Md1tSSq0=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0/go.mod h1:uF4ifVWSlieuvF0ZSy9m6flJJojvWuEu80pJIO3UJ0U=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
diff --git a/internal/hostenv/awsssm.go b/internal/hostenv/awsssm.go
new file mode 100644
index 000000000..edc0892a2
--- /dev/null
+++ b/internal/hostenv/awsssm.go
@@ -0,0 +1,98 @@
+package hostenv
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/ssm"
+ ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
+)
+
+// The AWS adapter for Params (region HA spec 2 §25): SSM Parameter Store
+// SecureStrings, encrypted with the deployment's configuration key (§4).
+// A box reads its two paths with the role's ssm:GetParametersByPath and
+// kms:Decrypt; an operator seeding them needs ssm:PutParameter and
+// kms:Encrypt on that key.
+
+type ssmParams struct {
+ client *ssm.Client
+ kmsKey string
+}
+
+// openSSMParams opens SSM in awsRegion ("" is the SDK's default region).
+// kmsKey is the key Set encrypts with.
+func openSSMParams(ctx context.Context, awsRegion, kmsKey string) (Params, error) {
+ var opts []func(*config.LoadOptions) error
+ if awsRegion != "" {
+ opts = append(opts, config.WithRegion(awsRegion))
+ }
+ cfg, err := config.LoadDefaultConfig(ctx, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return &ssmParams{client: ssm.NewFromConfig(cfg), kmsKey: kmsKey}, nil
+}
+
+// ByPath reads every parameter one level under prefix, decrypted, page by
+// page. Any failed page fails the whole read.
+func (p *ssmParams) ByPath(ctx context.Context, prefix string) (map[string]string, error) {
+ out := map[string]string{}
+ pages := ssm.NewGetParametersByPathPaginator(p.client, &ssm.GetParametersByPathInput{
+ Path: aws.String(strings.TrimSuffix(prefix, "/")),
+ Recursive: aws.Bool(false),
+ WithDecryption: aws.Bool(true),
+ })
+ for pages.HasMorePages() {
+ page, err := pages.NextPage(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, param := range page.Parameters {
+ name, ok := strings.CutPrefix(aws.ToString(param.Name), prefix)
+ if !ok || name == "" || strings.Contains(name, "/") {
+ continue
+ }
+ out[name] = aws.ToString(param.Value)
+ }
+ }
+ return out, nil
+}
+
+// Set writes one SecureString with the deployment's key, replacing it:
+// a region's own values.
+func (p *ssmParams) Set(ctx context.Context, name, value string) error {
+ in := &ssm.PutParameterInput{
+ Name: aws.String(name),
+ Value: aws.String(value),
+ Type: ssmtypes.ParameterTypeSecureString,
+ Overwrite: aws.Bool(true),
+ }
+ if p.kmsKey != "" {
+ in.KeyId = aws.String(p.kmsKey)
+ }
+ _, err := p.client.PutParameter(ctx, in)
+ return err
+}
+
+// SetNew writes one SecureString with the deployment's key only if it does
+// not exist (Overwrite false). SSM's ParameterAlreadyExists is ErrExists.
+func (p *ssmParams) SetNew(ctx context.Context, name, value string) error {
+ in := &ssm.PutParameterInput{
+ Name: aws.String(name),
+ Value: aws.String(value),
+ Type: ssmtypes.ParameterTypeSecureString,
+ Overwrite: aws.Bool(false),
+ }
+ if p.kmsKey != "" {
+ in.KeyId = aws.String(p.kmsKey)
+ }
+ _, err := p.client.PutParameter(ctx, in)
+ var exists *ssmtypes.ParameterAlreadyExists
+ if errors.As(err, &exists) {
+ return ErrExists
+ }
+ return err
+}
diff --git a/internal/hostenv/cli.go b/internal/hostenv/cli.go
new file mode 100644
index 000000000..671079b88
--- /dev/null
+++ b/internal/hostenv/cli.go
@@ -0,0 +1,115 @@
+package hostenv
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+// carlos system host-env write|seed (region HA spec 2 §5 step 2).
+//
+// - write is the start-up script's step: as root, it assembles host.env
+// from the deployment's parameters and writes it 0600. A missing
+// required value stops it, and the file is left as it was.
+// - seed is the operator's: it writes a box's existing host.env into
+// the parameter layout, once per region.
+
+const hostEnvUsage = `usage: carlos system host-env write --deployment <name> --region <code> [--aws-region <region>]
+ carlos system host-env seed --deployment <name> --region <code> --kms-key <key> [--aws-region <region>] [--from <path>]
+
+ write build host.env from the deployment's parameters (root)
+ seed copy a box's host.env into the deployment's parameters
+`
+
+// hostEnvRoot is the root write's fixed path is under: "/" on a box.
+// Tests point it at a directory of their own; there is no flag.
+var hostEnvRoot = "/"
+
+// openParams is the parameter store: the AWS adapter's (awsssm.go).
+// kmsKey is the key seed encrypts with; write passes "".
+var openParams = openSSMParams
+
+// RunSystemHostEnv is `carlos system host-env`.
+func RunSystemHostEnv(args []string) {
+ if err := runHostEnv(context.Background(), args, os.Stdout, os.Geteuid(), openParams); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func runHostEnv(ctx context.Context, args []string, out io.Writer, euid int, open func(ctx context.Context, awsRegion, kmsKey string) (Params, error)) error {
+ if len(args) == 0 || (args[0] != "write" && args[0] != "seed") {
+ return usageError()
+ }
+ verb := args[0]
+ fs := flag.NewFlagSet("system host-env "+verb, flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ deployment := fs.String("deployment", "", "")
+ region := fs.String("region", "", "")
+ awsRegion := fs.String("aws-region", "", "")
+ kmsKey := fs.String("kms-key", "", "")
+ from := fs.String("from", Path, "")
+ if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 || *deployment == "" || *region == "" {
+ return usageError()
+ }
+ switch verb {
+ case "write":
+ if euid != 0 {
+ return errors.New("carlos system host-env write runs as root: it writes a root-owned file")
+ }
+ p, err := open(ctx, *awsRegion, "")
+ if err != nil {
+ return err
+ }
+ body, err := Fetch(ctx, p, *deployment, *region)
+ if err != nil {
+ return fmt.Errorf("host.env was not written: %w", err)
+ }
+ // The path is fixed, /etc/carlos/host.env, and the file is written
+ // only through directories proved to be root's alone, then
+ // replaced whole (writeHostEnv).
+ if err := writeHostEnv(hostEnvRoot, body); err != nil {
+ return fmt.Errorf("host.env was not written: %w", err)
+ }
+ fmt.Fprintln(out, wroteFileLine(filepath.Join(hostEnvRoot, Path), *region))
+ return nil
+ default:
+ if *kmsKey == "" {
+ return usageError()
+ }
+ body, err := os.ReadFile(*from)
+ if err != nil {
+ return err
+ }
+ env, err := Parse(body)
+ if err != nil {
+ return fmt.Errorf("%s: %w", *from, err)
+ }
+ p, err := open(ctx, *awsRegion, *kmsKey)
+ if err != nil {
+ return err
+ }
+ s, err := Seed(ctx, p, *deployment, *region, env)
+ if err != nil {
+ return fmt.Errorf("the parameters were not all written: %w", err)
+ }
+ fmt.Fprintln(out, seededLine(s))
+ return nil
+ }
+}
+
+func usageError() error { return errors.New(hostEnvUsage) }
+
+// wroteFileLine is what write says once host.env is in place.
+func wroteFileLine(path, region string) string {
+ return fmt.Sprintf("wrote %s from the parameters for %s", path, region)
+}
+
+// seededLine is what seed says once the parameters are written.
+func seededLine(s Seeded) string {
+ return fmt.Sprintf("wrote %d parameters under %s and %d under %s", s.Regional, s.RegionPrefix, s.Shared, s.SharedPrefix)
+}
diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go
new file mode 100644
index 000000000..f73919fca
--- /dev/null
+++ b/internal/hostenv/hostenv.go
@@ -0,0 +1,437 @@
+// Package hostenv is a box's /etc/carlos/host.env as a parameter layout
+// (region HA spec 2 §5 step 2, §4). Every value a hosting box reads from
+// host.env is one parameter, named for its variable, under
+// /carlos/<deployment>/<region>/, or under /carlos/<deployment>/shared/
+// for the values every region of the deployment holds alike. A box's
+// start-up assembles host.env from the two; an operator seeds them from a
+// box's existing file.
+//
+// Where the parameters live is an adapter's business (awsssm.go, AWS SSM
+// SecureStrings). This file names no provider (§25): host.env is a file,
+// and Params is all the core asks of a parameter store.
+package hostenv
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+)
+
+// Path is the file a box's units read their environment from.
+const Path = "/etc/carlos/host.env"
+
+// SharedScope is the scope of the values every region holds alike.
+const SharedScope = "shared"
+
+// Required are the variables a hosting box cannot run without (§5 step 2):
+// the buckets and their region, the release and secrets keys, the box's
+// region and channel, the alert topic, and the claim and region HA
+// switches. Assembling host.env refuses without any of them.
+var Required = []string{
+ "CARLOS_ALERT_SNS_TOPIC_ARN",
+ "CARLOS_DEPLOYMENT_BUCKET",
+ "CARLOS_HIBERNATE_BUCKET",
+ "CARLOS_HIBERNATE_REGION",
+ "CARLOS_HOST_CLAIM",
+ "CARLOS_HOST_CLAIM_ORIGINS",
+ "CARLOS_REGION",
+ "CARLOS_REGION_HA",
+ "CARLOS_RELEASE_PUBKEY",
+ "CARLOS_SECRETS_KEY",
+ "CARLOS_SYSTEM_CHANNEL",
+}
+
+// Optional are the other variables a box its region's group launched may
+// carry, from the agent's config surface (README's host.env list and the
+// variables the agent reads). Together with Required they are the
+// allowlist: any other name, under either scope, is refused by name,
+// never by value, by write (Assemble) and by seed. Keys that would choose
+// a box's authority from the store alone are deliberately not on it: a
+// fleet's or tunnel's identity, an address override, a pool, the data,
+// opt and deployment directories, the relay's settings and any token or
+// credential. CARLOS_REGION and the group marker, which do select, are
+// cross-checked against the signed roster where the agent uses them
+// (internal/host's mode selection). Adding a variable is a code change,
+// reviewed.
+var Optional = []string{
+ "CARLOS_ACME_DELEGATION_ZONE",
+ "CARLOS_ACME_DIRECTORY",
+ "CARLOS_ALERT_CPU_PCT",
+ "CARLOS_ALERT_DISK_PCT",
+ "CARLOS_ALERT_MEM_PCT",
+ "CARLOS_ALERT_WEBHOOK",
+ "CARLOS_AWS_REGION",
+ "CARLOS_BACKHAUL_MAX_INFLIGHT",
+ "CARLOS_DNSIMPLE_TOKEN",
+ "CARLOS_DNS_PROVIDER",
+ "CARLOS_EDGE_GZIP",
+ "CARLOS_HIBERNATE_IDLE",
+ "CARLOS_HIBERNATE_PREFIX",
+ "CARLOS_HOST_CLAIM_RETIRED_LOCATIONS",
+ "CARLOS_HOST_METRICS",
+ "CARLOS_LAYOUT_DEFAULT",
+ "CARLOS_MOVES_PHASE1",
+ "CARLOS_MOVE_READY_DELTA_BYTES",
+ "CARLOS_REQUIRE_POINTER_DIGEST",
+ "CARLOS_S3_ENDPOINT",
+ "CARLOS_S3_QUIRKS",
+ "CARLOS_STATUS_BUCKET",
+ "CARLOS_STATUS_PREFIX",
+ "CARLOS_STATUS_REGION",
+ "CARLOS_STEERING_ZONE_ID",
+ "CARLOS_SWAP",
+ "CARLOS_UNIT_SETTLE",
+ "CARLOS_VERSIONS_GRACE",
+ "CARLOS_WILDCARD_DOMAINS",
+ "STATUS_BOX_REGION",
+}
+
+// Allowed reports whether name is a variable host.env may carry from the
+// parameters: Required or Optional.
+func Allowed(name string) bool {
+ return slices.Contains(Required, name) || slices.Contains(Optional, name)
+}
+
+// notAllowed lists, sorted, the names in env that are not Allowed.
+func notAllowed(env map[string]string) []string {
+ var out []string
+ for k := range env {
+ if k != BoxReference && !Allowed(k) {
+ out = append(out, k)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// SharedKeys are the variables that are the same in every region of a
+// deployment, so they are kept once, under the shared scope. Everything
+// else is the region's own.
+var SharedKeys = []string{
+ "CARLOS_DEPLOYMENT_BUCKET",
+ "CARLOS_HOST_CLAIM",
+ "CARLOS_HOST_CLAIM_ORIGINS",
+ "CARLOS_RELEASE_PUBKEY",
+ "CARLOS_SECRETS_KEY",
+}
+
+// BoxReference and GroupReference are the one line host.env carries that
+// is not a parameter. write runs only in the start-up of a box its
+// region's group launched, so a host.env it wrote marks such a box, and
+// the box takes the reference its account and instance derive
+// (hostactors.GroupBoxReference) instead of a status label. Every other
+// box's host.env lacks it and keeps its label.
+const (
+ BoxReference = "CARLOS_BOX_REFERENCE"
+ GroupReference = "group"
+)
+
+// Params is a store of named, encrypted parameters.
+type Params interface {
+ // ByPath returns every parameter directly under prefix, keyed by its
+ // name after the prefix, decrypted. A read that fails or stops part
+ // way is an error, never a partial map.
+ ByPath(ctx context.Context, prefix string) (map[string]string, error)
+ // Set writes one parameter, encrypted, replacing any value it had.
+ Set(ctx context.Context, name, value string) error
+ // SetNew writes one parameter, encrypted, only if it does not exist:
+ // ErrExists when it does, and the value is not changed.
+ SetNew(ctx context.Context, name, value string) error
+}
+
+var (
+ // ErrMissing is a required variable with no value.
+ ErrMissing = errors.New("required values are missing")
+ // ErrConflict is a variable held both by the region and shared, or a
+ // shared value seeding would change.
+ ErrConflict = errors.New("values conflict")
+ // ErrValue is a name or value host.env cannot hold safely.
+ ErrValue = errors.New("a name or value host.env cannot hold")
+ // ErrExists is SetNew's answer for a parameter that is already set:
+ // another seed wrote it first.
+ ErrExists = errors.New("already set by another seed")
+)
+
+var (
+ scopeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
+ keyName = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
+ safeValue = regexp.MustCompile(`^[A-Za-z0-9_./:,@+=%-]*$`)
+)
+
+// Prefix is the parameter path of one scope: a region's placement code or
+// SharedScope. Both names are refused unless they are lower-case labels.
+func Prefix(deployment, scope string) (string, error) {
+ if !scopeName.MatchString(deployment) || !scopeName.MatchString(scope) {
+ return "", fmt.Errorf("%w: %q and %q must be lower-case labels", ErrValue, deployment, scope)
+ }
+ return "/carlos/" + deployment + "/" + scope + "/", nil
+}
+
+// ValidKey reports whether name is a variable host.env carries. Other
+// parameters under a scope (the bootstrap object's sha256, for one) are
+// not variables and are skipped.
+func ValidKey(name string) bool { return keyName.MatchString(name) }
+
+// Assemble merges the shared and the region's values. A variable in both
+// is a conflict, never resolved by precedence: one of the two is wrong.
+// Every Required variable must have a value.
+func Assemble(shared, regional map[string]string) (map[string]string, error) {
+ env := map[string]string{}
+ var both []string
+ for _, src := range []map[string]string{shared, regional} {
+ for k, v := range src {
+ if !ValidKey(k) || k == BoxReference {
+ continue
+ }
+ if _, dup := env[k]; dup {
+ both = append(both, k)
+ continue
+ }
+ env[k] = v
+ }
+ }
+ if len(both) > 0 {
+ sort.Strings(both)
+ return nil, fmt.Errorf("%w: %s set both for the region and shared", ErrConflict, strings.Join(both, ", "))
+ }
+ if bad := notAllowed(env); len(bad) > 0 {
+ return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
+ }
+ if missing := Missing(env); len(missing) > 0 {
+ return nil, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", "))
+ }
+ return env, nil
+}
+
+// Missing lists the Required variables env has no value for, sorted.
+func Missing(env map[string]string) []string {
+ var out []string
+ for _, k := range Required {
+ if strings.TrimSpace(env[k]) == "" {
+ out = append(out, k)
+ }
+ }
+ return out
+}
+
+// renderedHeader is the first line of a host.env Render writes.
+const renderedHeader = "# Written by carlos system host-env write from the deployment's parameters. Do not edit by hand."
+
+// Render is host.env's bytes: one KEY=value line per variable, sorted.
+// Values are written bare, so the file means the same to systemd's
+// EnvironmentFile and to a shell that sources it. A value outside the safe
+// set (letters, digits and _ . / : , @ + = % -) is refused rather than
+// quoted; the error names the variables, never their values, which may be
+// secrets.
+func Render(env map[string]string) ([]byte, error) {
+ keys := make([]string, 0, len(env))
+ var bad []string
+ for k, v := range env {
+ if !ValidKey(k) || !safeValue.MatchString(v) {
+ bad = append(bad, k)
+ continue
+ }
+ keys = append(keys, k)
+ }
+ if len(bad) > 0 {
+ sort.Strings(bad)
+ return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
+ }
+ sort.Strings(keys)
+ var b bytes.Buffer
+ b.WriteString(renderedHeader + "\n")
+ for _, k := range keys {
+ b.WriteString(k + "=" + env[k] + "\n")
+ }
+ return b.Bytes(), nil
+}
+
+// Parse reads an existing host.env: KEY=value lines, with blank lines,
+// comments and a leading "export " ignored, and one layer of matching
+// quotes removed from a value. A variable set twice, or a line that is
+// not an assignment, is refused.
+func Parse(body []byte) (map[string]string, error) {
+ env := map[string]string{}
+ sc := bufio.NewScanner(bytes.NewReader(body))
+ for n := 1; sc.Scan(); n++ {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ line = strings.TrimPrefix(line, "export ")
+ k, v, ok := strings.Cut(line, "=")
+ k = strings.TrimSpace(k)
+ if !ok || !ValidKey(k) {
+ return nil, fmt.Errorf("%w: line %d is not KEY=value", ErrValue, n)
+ }
+ v = strings.TrimSpace(v)
+ if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
+ v = v[1 : len(v)-1]
+ }
+ if _, dup := env[k]; dup {
+ return nil, fmt.Errorf("%w: %s is set twice", ErrValue, k)
+ }
+ env[k] = v
+ }
+ if err := sc.Err(); err != nil {
+ return nil, err
+ }
+ return env, nil
+}
+
+// Split divides env into the shared variables and the region's own.
+func Split(env map[string]string) (shared, regional map[string]string) {
+ shared, regional = map[string]string{}, map[string]string{}
+ for k, v := range env {
+ if slices.Contains(SharedKeys, k) {
+ shared[k] = v
+ } else {
+ regional[k] = v
+ }
+ }
+ return shared, regional
+}
+
+// Fetch assembles host.env for one region of a deployment from p, for a
+// box the region's group launched: it adds BoxReference.
+func Fetch(ctx context.Context, p Params, deployment, region string) ([]byte, error) {
+ if region == SharedScope {
+ return nil, fmt.Errorf("%w: %q is not a region", ErrValue, region)
+ }
+ sharedPrefix, err := Prefix(deployment, SharedScope)
+ if err != nil {
+ return nil, err
+ }
+ regionPrefix, err := Prefix(deployment, region)
+ if err != nil {
+ return nil, err
+ }
+ shared, err := p.ByPath(ctx, sharedPrefix)
+ if err != nil {
+ return nil, fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
+ }
+ regional, err := p.ByPath(ctx, regionPrefix)
+ if err != nil {
+ return nil, fmt.Errorf("parameters under %s: %w", regionPrefix, err)
+ }
+ env, err := Assemble(shared, regional)
+ if err != nil {
+ return nil, err
+ }
+ env[BoxReference] = GroupReference
+ return Render(env)
+}
+
+// Seeded is what Seed wrote: the parameters under each prefix.
+type Seeded struct {
+ SharedPrefix, RegionPrefix string
+ Shared, Regional int
+}
+
+// Seed writes env, a box's existing host.env, into the layout for region.
+// The region's variables are written over whatever is there. A shared
+// variable is written only when it is not yet set, and create-only
+// (SetNew): one region's file never changes a value every region reads.
+// A shared value that differs is a conflict, and nothing at all is
+// written. Two seeds racing for the same shared variable cannot both
+// win: the one SetNew refuses reads the value back and goes on only if
+// it is the value it would have written. env must hold every Required
+// variable, in a form Render accepts. BoxReference is never seeded: it
+// is write's.
+func Seed(ctx context.Context, p Params, deployment, region string, env map[string]string) (Seeded, error) {
+ if region == SharedScope {
+ return Seeded{}, fmt.Errorf("%w: %q is not a region", ErrValue, region)
+ }
+ sharedPrefix, err := Prefix(deployment, SharedScope)
+ if err != nil {
+ return Seeded{}, err
+ }
+ regionPrefix, err := Prefix(deployment, region)
+ if err != nil {
+ return Seeded{}, err
+ }
+ if missing := Missing(env); len(missing) > 0 {
+ return Seeded{}, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", "))
+ }
+ if bad := notAllowed(env); len(bad) > 0 {
+ return Seeded{}, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", "))
+ }
+ if _, err := Render(env); err != nil {
+ return Seeded{}, err
+ }
+ shared, regional := Split(env)
+ delete(regional, BoxReference)
+ existing, err := p.ByPath(ctx, sharedPrefix)
+ if err != nil {
+ return Seeded{}, fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
+ }
+ var differ []string
+ for k, v := range shared {
+ if have, ok := existing[k]; ok && have != v {
+ differ = append(differ, k)
+ }
+ }
+ if len(differ) > 0 {
+ sort.Strings(differ)
+ return Seeded{}, fmt.Errorf("%w: the shared %s already differ", ErrConflict, strings.Join(differ, ", "))
+ }
+ out := Seeded{SharedPrefix: sharedPrefix, RegionPrefix: regionPrefix}
+ for _, k := range sortedKeys(shared) {
+ if _, ok := existing[k]; ok {
+ continue
+ }
+ err := p.SetNew(ctx, sharedPrefix+k, shared[k])
+ if errors.Is(err, ErrExists) {
+ // Another seed set it since the read above: go on only if it
+ // set what this one would have.
+ if err := sameShared(ctx, p, sharedPrefix, k, shared[k]); err != nil {
+ return out, err
+ }
+ continue
+ }
+ if err != nil {
+ return out, fmt.Errorf("%s%s: %w", sharedPrefix, k, err)
+ }
+ out.Shared++
+ }
+ for _, k := range sortedKeys(regional) {
+ if err := p.Set(ctx, regionPrefix+k, regional[k]); err != nil {
+ return out, fmt.Errorf("%s%s: %w", regionPrefix, k, err)
+ }
+ out.Regional++
+ }
+ return out, nil
+}
+
+// sameShared reads the shared variable k back after SetNew found it set,
+// and refuses unless it holds want. The error names the variable, never
+// either value.
+func sameShared(ctx context.Context, p Params, sharedPrefix, k, want string) error {
+ now, err := p.ByPath(ctx, sharedPrefix)
+ if err != nil {
+ return fmt.Errorf("parameters under %s: %w", sharedPrefix, err)
+ }
+ have, ok := now[k]
+ if !ok {
+ return fmt.Errorf("%s%s: %w", sharedPrefix, k, ErrExists)
+ }
+ if have != want {
+ return fmt.Errorf("%w: the shared %s already differ", ErrConflict, k)
+ }
+ return nil
+}
+
+func sortedKeys(m map[string]string) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/internal/hostenv/hostenv_test.go b/internal/hostenv/hostenv_test.go
new file mode 100644
index 000000000..a552a0e33
--- /dev/null
+++ b/internal/hostenv/hostenv_test.go
@@ -0,0 +1,365 @@
+package hostenv
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "slices"
+ "strings"
+ "testing"
+)
+
+// fakeParams is a parameter store in memory. failPrefix makes ByPath fail
+// for one prefix, as an unreachable or refused SSM call would. staleOnce
+// makes the next ByPath of that prefix answer as it stood before every
+// value now under it was written: a seed that read just before another
+// seed wrote.
+type fakeParams struct {
+ vals map[string]string
+ sets []string
+ failPrefix string
+ staleOnce string
+}
+
+func (f *fakeParams) ByPath(_ context.Context, prefix string) (map[string]string, error) {
+ if prefix == f.failPrefix {
+ return nil, errors.New("AccessDeniedException")
+ }
+ if prefix == f.staleOnce {
+ f.staleOnce = ""
+ return map[string]string{}, nil
+ }
+ out := map[string]string{}
+ for name, v := range f.vals {
+ if rest, ok := strings.CutPrefix(name, prefix); ok && !strings.Contains(rest, "/") {
+ out[rest] = v
+ }
+ }
+ return out, nil
+}
+
+func (f *fakeParams) Set(_ context.Context, name, value string) error {
+ f.vals[name] = value
+ f.sets = append(f.sets, name)
+ return nil
+}
+
+func (f *fakeParams) SetNew(_ context.Context, name, value string) error {
+ if _, ok := f.vals[name]; ok {
+ return ErrExists
+ }
+ f.vals[name] = value
+ f.sets = append(f.sets, name)
+ return nil
+}
+
+// today is a hosting box's host.env as an operator has it today.
+func today() map[string]string {
+ return map[string]string{
+ "CARLOS_ALERT_SNS_TOPIC_ARN": "arn:aws:sns:eu-west-1:123456789012:carlos-alerts",
+ "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket",
+ "CARLOS_HIBERNATE_BUCKET": "data-ie",
+ "CARLOS_HIBERNATE_REGION": "eu-west-1",
+ "CARLOS_HOST_CLAIM": "1",
+ "CARLOS_HOST_CLAIM_ORIGINS": "1",
+ "CARLOS_REGION": "ie",
+ "CARLOS_REGION_HA": "1",
+ "CARLOS_RELEASE_PUBKEY": "q3J0bXlwdWJsaWNrZXk+/=",
+ "CARLOS_SECRETS_KEY": "c2VjcmV0c2tleQ==",
+ "CARLOS_SYSTEM_CHANNEL": "stable",
+ "CARLOS_STATUS_PREFIX": "fleet",
+ }
+}
+
+func TestSeedThenFetchIsTodaysFile(t *testing.T) {
+ ctx := context.Background()
+ p := &fakeParams{vals: map[string]string{}}
+ s, err := Seed(ctx, p, "flagship", "ie", today())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Shared != len(SharedKeys) || s.Regional != len(today())-len(SharedKeys) || s.SharedPrefix != "/carlos/flagship/shared/" || s.RegionPrefix != "/carlos/flagship/ie/" {
+ t.Fatalf("seeded %+v", s)
+ }
+ if p.vals["/carlos/flagship/shared/CARLOS_RELEASE_PUBKEY"] != today()["CARLOS_RELEASE_PUBKEY"] || p.vals["/carlos/flagship/ie/CARLOS_REGION"] != "ie" {
+ t.Fatalf("layout %v", p.vals)
+ }
+ // Another region's parameter, and a non-variable under shared, are not
+ // this region's host.env.
+ p.vals["/carlos/flagship/uk/CARLOS_REGION"] = "uk"
+ p.vals["/carlos/flagship/shared/bootstrap-sha256"] = strings.Repeat("a", 64)
+ body, err := Fetch(ctx, p, "flagship", "ie")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := Parse(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A box write assembles host.env for is one its group launched: it
+ // takes its group reference (BoxReference), which is never a parameter.
+ want := today()
+ want[BoxReference] = GroupReference
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("round trip:\n got %v\nwant %v", got, want)
+ }
+ if !bytes.HasPrefix(body, []byte("# ")) || !bytes.Contains(body, []byte("\nCARLOS_ALERT_SNS_TOPIC_ARN=arn:aws:sns:eu-west-1:123456789012:carlos-alerts\nCARLOS_BOX_REFERENCE=group\nCARLOS_DEPLOYMENT_BUCKET=deploy-bucket\n")) {
+ t.Fatalf("not sorted KEY=value lines:\n%s", body)
+ }
+}
+
+func TestFetchRefusesAMissingRequiredValue(t *testing.T) {
+ ctx := context.Background()
+ for _, k := range Required {
+ p := &fakeParams{vals: map[string]string{}}
+ env := today()
+ if _, err := Seed(ctx, p, "flagship", "ie", env); err != nil {
+ t.Fatal(err)
+ }
+ for name := range p.vals {
+ if strings.HasSuffix(name, "/"+k) {
+ delete(p.vals, name)
+ }
+ }
+ _, err := Fetch(ctx, p, "flagship", "ie")
+ if !errors.Is(err, ErrMissing) || !strings.Contains(err.Error(), k) {
+ t.Fatalf("without %s: %v", k, err)
+ }
+ }
+}
+
+func TestFetchRefusesAFailedReadAndAConflict(t *testing.T) {
+ ctx := context.Background()
+ p := &fakeParams{vals: map[string]string{}}
+ if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
+ t.Fatal(err)
+ }
+ // A refused read is never an empty scope: nothing is assembled.
+ p.failPrefix = "/carlos/flagship/shared/"
+ if _, err := Fetch(ctx, p, "flagship", "ie"); err == nil || errors.Is(err, ErrMissing) {
+ t.Fatalf("failed read: %v", err)
+ }
+ p.failPrefix = ""
+ // A variable in both scopes is one of them wrong.
+ p.vals["/carlos/flagship/ie/CARLOS_RELEASE_PUBKEY"] = "other"
+ if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_RELEASE_PUBKEY") {
+ t.Fatalf("conflict: %v", err)
+ }
+}
+
+func TestRenderRefusesAnUnsafeValueWithoutPrintingIt(t *testing.T) {
+ env := today()
+ env["CARLOS_SECRETS_KEY"] = "abc$(reboot)"
+ _, err := Render(env)
+ if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "reboot") {
+ t.Fatalf("unsafe value: %v", err)
+ }
+ for _, v := range []string{"a b", "a'b", `a"b`, "a\\b", "a\nb", "a#b", "a;b"} {
+ if _, err := Render(map[string]string{"CARLOS_X": v}); !errors.Is(err, ErrValue) {
+ t.Fatalf("%q rendered: %v", v, err)
+ }
+ }
+}
+
+func TestSeedNeverChangesASharedValue(t *testing.T) {
+ ctx := context.Background()
+ p := &fakeParams{vals: map[string]string{}}
+ if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
+ t.Fatal(err)
+ }
+ // A second region with the same shared values writes only its own.
+ uk := today()
+ uk["CARLOS_REGION"], uk["CARLOS_HIBERNATE_BUCKET"] = "uk", "data-uk"
+ p.sets = nil
+ s, err := Seed(ctx, p, "flagship", "uk", uk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Shared != 0 {
+ t.Fatalf("rewrote shared values: %v", p.sets)
+ }
+ // A third region whose file disagrees on a shared value writes nothing.
+ au := today()
+ au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI="
+ p.sets = nil
+ if _, err := Seed(ctx, p, "flagship", "au", au); !errors.Is(err, ErrConflict) || len(p.sets) != 0 {
+ t.Fatalf("conflicting shared value: %v, wrote %v", err, p.sets)
+ }
+ // A group box's own host.env seeds no BoxReference.
+ grp := today()
+ grp[BoxReference] = GroupReference
+ p.sets = nil
+ if _, err := Seed(ctx, p, "flagship", "ie", grp); err != nil || slices.ContainsFunc(p.sets, func(n string) bool { return strings.HasSuffix(n, BoxReference) }) {
+ t.Fatalf("seeded %v: %v", p.sets, err)
+ }
+ // An incomplete file writes nothing either.
+ partial := today()
+ delete(partial, "CARLOS_REGION_HA")
+ p.sets = nil
+ if _, err := Seed(ctx, p, "flagship", "au", partial); !errors.Is(err, ErrMissing) || len(p.sets) != 0 {
+ t.Fatalf("incomplete file: %v, wrote %v", err, p.sets)
+ }
+}
+
+// R4: two seeds racing for a shared value. The second read before the
+// first wrote, so both try to create it; the second's SetNew is refused,
+// it reads the value back, and it goes on only if the value is its own.
+func TestSeedRacesForASharedValueAndOnlyOneWins(t *testing.T) {
+ ctx := context.Background()
+ p := &fakeParams{vals: map[string]string{}}
+ if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
+ t.Fatal(err)
+ }
+ // The same shared values: nothing shared is written, the region's are.
+ uk := today()
+ uk["CARLOS_REGION"] = "uk"
+ p.staleOnce, p.sets = "/carlos/flagship/shared/", nil
+ s, err := Seed(ctx, p, "flagship", "uk", uk)
+ if err != nil || s.Shared != 0 || s.Regional != len(today())-len(SharedKeys) {
+ t.Fatalf("same values: %+v %v, wrote %v", s, err, p.sets)
+ }
+ // A different secrets key: refused, naming the variable and never
+ // either value, and the first seed's value stands.
+ au := today()
+ au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI="
+ p.staleOnce, p.sets = "/carlos/flagship/shared/", nil
+ _, err = Seed(ctx, p, "flagship", "au", au)
+ if !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "b3RoZXI=") || strings.Contains(err.Error(), today()["CARLOS_SECRETS_KEY"]) {
+ t.Fatalf("racing seed: %v", err)
+ }
+ if p.vals["/carlos/flagship/shared/CARLOS_SECRETS_KEY"] != today()["CARLOS_SECRETS_KEY"] || len(p.sets) != 0 {
+ t.Fatalf("the second seed changed or wrote %v", p.sets)
+ }
+}
+
+func TestPrefixAndParse(t *testing.T) {
+ if _, err := Prefix("flagship", "../x"); !errors.Is(err, ErrValue) {
+ t.Fatalf("bad scope: %v", err)
+ }
+ if _, err := Fetch(context.Background(), &fakeParams{}, "flagship", SharedScope); !errors.Is(err, ErrValue) {
+ t.Fatalf("shared as a region: %v", err)
+ }
+ got, err := Parse([]byte("# comment\n\nexport CARLOS_REGION=\"ie\"\nCARLOS_HOST_CLAIM='1'\n"))
+ if err != nil || !reflect.DeepEqual(got, map[string]string{"CARLOS_REGION": "ie", "CARLOS_HOST_CLAIM": "1"}) {
+ t.Fatalf("parse: %v %v", got, err)
+ }
+ for _, body := range []string{"CARLOS_REGION=ie\nCARLOS_REGION=uk\n", "not an assignment\n", "lower=x\n"} {
+ if _, err := Parse([]byte(body)); !errors.Is(err, ErrValue) {
+ t.Fatalf("%q parsed: %v", body, err)
+ }
+ }
+}
+
+// The command: write refuses without root and leaves no file on a failed
+// assembly; with root it writes 0600; seed reads the named file.
+func TestHostEnvCommand(t *testing.T) {
+ ctx := context.Background()
+ dir := withHostEnvRoot(t)
+ out := filepath.Join(dir, "etc", "carlos", "host.env")
+ p := &fakeParams{vals: map[string]string{}}
+ open := func(context.Context, string, string) (Params, error) { return p, nil }
+ var stdout bytes.Buffer
+ args := []string{"write", "--deployment", "flagship", "--region", "ie"}
+ if err := runHostEnv(ctx, args, &stdout, 1000, open); err == nil || !strings.Contains(err.Error(), "root") {
+ t.Fatalf("non-root write: %v", err)
+ }
+ if err := runHostEnv(ctx, args, &stdout, 0, open); !errors.Is(err, ErrMissing) {
+ t.Fatalf("write with no parameters: %v", err)
+ }
+ if _, err := os.Stat(out); !os.IsNotExist(err) {
+ t.Fatalf("a failed write left a file: %v", err)
+ }
+ from := filepath.Join(dir, "today.env")
+ body, _ := Render(today())
+ if err := os.WriteFile(from, body, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ seed := []string{"seed", "--deployment", "flagship", "--region", "ie", "--kms-key", "alias/carlos-flagship-config", "--from", from}
+ if err := runHostEnv(ctx, seed, &stdout, 1000, open); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(stdout.String(), "wrote 7 parameters under /carlos/flagship/ie/ and 5 under /carlos/flagship/shared/\n") {
+ t.Fatalf("output %q", stdout.String())
+ }
+ err := runHostEnv(ctx, args, &stdout, 0, open)
+ if runtime.GOOS != "linux" {
+ if err == nil || !strings.Contains(err.Error(), "runs only on Linux") {
+ t.Fatalf("write off Linux: %v", err)
+ }
+ } else {
+ if err != nil {
+ t.Fatal(err)
+ }
+ fi, err := os.Stat(out)
+ if err != nil || fi.Mode().Perm() != 0o600 {
+ t.Fatalf("host.env: %v %v", fi, err)
+ }
+ if !strings.Contains(stdout.String(), "wrote "+out+" from the parameters for ie\n") {
+ t.Fatalf("output %q", stdout.String())
+ }
+ }
+ // The path is fixed: there is no flag to move it.
+ for _, bad := range [][]string{nil, {"seed", "--deployment", "flagship", "--region", "ie"}, {"write", "--region", "ie"}, {"read"}, append(args, "--out", filepath.Join(dir, "elsewhere"))} {
+ if err := runHostEnv(ctx, bad, &stdout, 0, open); err == nil || !strings.HasPrefix(err.Error(), "usage: carlos system host-env") {
+ t.Fatalf("%v: %v", bad, err)
+ }
+ }
+}
+
+// runHostEnv's census conditions (platform-1e, region-ha-2b): write takes
+// only allowlisted names, refuses values that could start a second line,
+// end the file or change how systemd reads it, and writes through no
+// symlinked directory. A refusal names the variable, never the value.
+func TestWriteTakesOnlyAllowedNamesAndSafeValues(t *testing.T) {
+ ctx := context.Background()
+ p := &fakeParams{vals: map[string]string{}}
+ if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil {
+ t.Fatal(err)
+ }
+ // A name off the allowlist, under either scope, is refused by name.
+ for _, name := range []string{"/carlos/flagship/ie/CARLOS_PRIVATE_IP", "/carlos/flagship/shared/CARLOS_FLEET"} {
+ p.vals[name] = "10.0.0.9"
+ _, err := Fetch(ctx, p, "flagship", "ie")
+ short := name[strings.LastIndex(name, "/")+1:]
+ if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), short) || strings.Contains(err.Error(), "10.0.0.9") {
+ t.Fatalf("%s: %v", name, err)
+ }
+ delete(p.vals, name)
+ }
+ // Seeding a file with such a name writes nothing.
+ odd := today()
+ odd["CARLOS_POOLS"] = "gold"
+ p.sets = nil
+ if _, err := Seed(ctx, p, "flagship", "uk", odd); !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_POOLS") || len(p.sets) != 0 {
+ t.Fatalf("seed with an unknown name: %v, wrote %v", err, p.sets)
+ }
+ // Values that would break a line, end the file early or change how
+ // systemd reads it.
+ for _, v := range []string{"a\nCARLOS_REGION=uk", "a\x00b", "a\rb", "$(id)", "a b", "\"quoted\""} {
+ p.vals["/carlos/flagship/ie/CARLOS_STATUS_PREFIX"] = v
+ if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrValue) || strings.Contains(err.Error(), v) {
+ t.Fatalf("%q: %v", v, err)
+ }
+ }
+}
+
+// withHostEnvRoot points write's fixed path at a directory of the test's
+// own, with an etc in it as a box has one.
+func withHostEnvRoot(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ if err := os.Mkdir(filepath.Join(root, "etc"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(filepath.Join(root, "etc"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ was := hostEnvRoot
+ hostEnvRoot = root
+ t.Cleanup(func() { hostEnvRoot = was })
+ return root
+}
diff --git a/internal/hostenv/write_linux.go b/internal/hostenv/write_linux.go
new file mode 100644
index 000000000..24cf5c2ef
--- /dev/null
+++ b/internal/hostenv/write_linux.go
@@ -0,0 +1,108 @@
+//go:build linux
+
+package hostenv
+
+import (
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "golang.org/x/sys/unix"
+)
+
+// writeHostEnv writes body as <root>/etc/carlos/host.env, 0600, owned by
+// the process's user (root on a box). Every directory on the way is held
+// by descriptor, never looked up again by name: etc and etc/carlos are
+// opened with O_NOFOLLOW|O_DIRECTORY, so a symlink at either is refused,
+// and each must be owned by this user and writable by no one else
+// (checkedDir), so no other user can swap the file or the directory under
+// it. etc/carlos is created 0755 if it is missing, then opened and
+// checked like the rest. The new file is written beside the old one in
+// that directory, synced, renamed over it, and the directory synced, so a
+// reader sees the old file or the new one, never part of either.
+func writeHostEnv(root string, body []byte) error {
+ rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(rootFD)
+ etc, err := checkedDir(rootFD, filepath.Join(root, "etc"), "etc", false)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(etc)
+ dir, err := checkedDir(etc, filepath.Join(root, "etc", "carlos"), "carlos", true)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(dir)
+ tmp := ".host.env." + rand.Text()
+ fd, err := unix.Openat(dir, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
+ if err != nil {
+ return err
+ }
+ defer unix.Unlinkat(dir, tmp, 0) // gone once renamed
+ f := os.NewFile(uintptr(fd), tmp)
+ if err := f.Chmod(0o600); err != nil {
+ f.Close()
+ return err
+ }
+ if _, err := f.Write(body); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ if err := unix.Renameat(dir, tmp, dir, "host.env"); err != nil {
+ return err
+ }
+ return unix.Fsync(dir)
+}
+
+// checkedDir opens name under the directory parent as a real directory,
+// creating it 0755 first when create is set and it is missing, and refuses
+// one that is a symlink, is not a directory, is not owned by this
+// process's user (hostEnvOwner), or is writable by group or others. path
+// is its name for the refusal.
+func checkedDir(parent int, path, name string, create bool) (int, error) {
+ const how = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
+ fd, err := unix.Openat(parent, name, how, 0)
+ if errors.Is(err, unix.ENOENT) && create {
+ if err := unix.Mkdirat(parent, name, 0o755); err != nil && !errors.Is(err, unix.EEXIST) {
+ return -1, err
+ }
+ fd, err = unix.Openat(parent, name, how, 0)
+ }
+ if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) {
+ return -1, notRootsDir(path)
+ }
+ if err != nil {
+ return -1, err
+ }
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ unix.Close(fd)
+ return -1, err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFDIR || int(st.Uid) != hostEnvOwner() || st.Mode&0o022 != 0 {
+ unix.Close(fd)
+ return -1, notRootsDir(path)
+ }
+ return fd, nil
+}
+
+// hostEnvOwner is the user etc and etc/carlos must belong to: this
+// process's, which runHostEnv has already required to be root. A var so a
+// test can stand for a directory another user owns.
+var hostEnvOwner = os.Geteuid
+
+func notRootsDir(path string) error {
+ return fmt.Errorf("%s is not a directory only root can write", path)
+}
diff --git a/internal/hostenv/write_linux_test.go b/internal/hostenv/write_linux_test.go
new file mode 100644
index 000000000..4aed0f6a0
--- /dev/null
+++ b/internal/hostenv/write_linux_test.go
@@ -0,0 +1,116 @@
+//go:build linux
+
+package hostenv
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// U3 (census condition 2 on runHostEnv): write's path is fixed, and the
+// file is written only through etc and etc/carlos held by descriptor,
+// each a real directory this user owns and nobody else can write.
+func TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite(t *testing.T) {
+ read := func(root string) string {
+ b, _ := os.ReadFile(filepath.Join(root, "etc", "carlos", "host.env"))
+ return string(b)
+ }
+ t.Run("lands whole, and replaces whole", func(t *testing.T) {
+ root := withHostEnvRoot(t)
+ if err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")); err != nil {
+ t.Fatal(err)
+ }
+ fi, err := os.Stat(filepath.Join(root, "etc", "carlos"))
+ if err != nil || fi.Mode().Perm()&0o022 != 0 || !fi.IsDir() {
+ t.Fatalf("created directory %v %v", fi, err)
+ }
+ first, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := writeHostEnv(root, []byte("CARLOS_REGION=uk\n")); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env"))
+ if err != nil || f.Mode().Perm() != 0o600 || read(root) != "CARLOS_REGION=uk\n" {
+ t.Fatalf("host.env %v %v %q", f, err, read(root))
+ }
+ // Replaced by rename, not rewritten in place: a reader holding the
+ // old file never sees part of the new one.
+ if os.SameFile(first, f) {
+ t.Fatal("host.env was rewritten in place")
+ }
+ ents, _ := os.ReadDir(filepath.Join(root, "etc", "carlos"))
+ if len(ents) != 1 {
+ t.Fatalf("left behind: %v", ents)
+ }
+ })
+ for name, spoil := range map[string]func(t *testing.T, root string){
+ "a group-writable etc/carlos": func(t *testing.T, root string) {
+ dir := filepath.Join(root, "etc", "carlos")
+ if err := os.Mkdir(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o775); err != nil {
+ t.Fatal(err)
+ }
+ },
+ "a world-writable etc": func(t *testing.T, root string) {
+ if err := os.Chmod(filepath.Join(root, "etc"), 0o757); err != nil {
+ t.Fatal(err)
+ }
+ },
+ "an etc/carlos another user owns": func(t *testing.T, root string) {
+ if err := os.Mkdir(filepath.Join(root, "etc", "carlos"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ was := hostEnvOwner
+ hostEnvOwner = func() int { return was() + 1 }
+ t.Cleanup(func() { hostEnvOwner = was })
+ },
+ "a symlinked etc/carlos": func(t *testing.T, root string) {
+ elsewhere := safeDir(t)
+ if err := os.Symlink(elsewhere, filepath.Join(root, "etc", "carlos")); err != nil {
+ t.Fatal(err)
+ }
+ },
+ "a symlinked etc, an ancestor": func(t *testing.T, root string) {
+ elsewhere := safeDir(t)
+ if err := os.Mkdir(filepath.Join(elsewhere, "carlos"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(root, "etc")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(elsewhere, filepath.Join(root, "etc")); err != nil {
+ t.Fatal(err)
+ }
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ root := withHostEnvRoot(t)
+ spoil(t, root)
+ err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n"))
+ if err == nil || !strings.Contains(err.Error(), "is not a directory only root can write") {
+ t.Fatalf("%v", err)
+ }
+ if got := read(root); got != "" {
+ t.Fatalf("wrote %q", got)
+ }
+ })
+ }
+}
+
+// safeDir is a directory that passes every check but the one under test:
+// this user's, 0755 whatever the umask, so a symlink to it is refused only
+// for being a symlink.
+func safeDir(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.Chmod(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
diff --git a/internal/hostenv/write_other.go b/internal/hostenv/write_other.go
new file mode 100644
index 000000000..a383b0cec
--- /dev/null
+++ b/internal/hostenv/write_other.go
@@ -0,0 +1,11 @@
+//go:build !linux
+
+package hostenv
+
+import "errors"
+
+// writeHostEnv is Linux's: a box that writes host.env runs Linux, and only
+// there are its directories opened and checked by descriptor.
+func writeHostEnv(root string, body []byte) error {
+ return errors.New("carlos system host-env write runs only on Linux")
+}
diff --git a/internal/hostenv/write_other_test.go b/internal/hostenv/write_other_test.go
new file mode 100644
index 000000000..82575fd3e
--- /dev/null
+++ b/internal/hostenv/write_other_test.go
@@ -0,0 +1,15 @@
+//go:build !linux
+
+package hostenv
+
+import "testing"
+
+// U3: off Linux there is no checked write, so write refuses and leaves
+// nothing behind.
+func TestWriteRefusesOffLinux(t *testing.T) {
+ root := t.TempDir()
+ err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n"))
+ if err == nil || err.Error() != "carlos system host-env write runs only on Linux" {
+ t.Fatalf("%v", err)
+ }
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index e0fd546ec..2371cba3c 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -806,6 +806,17 @@ func TestPlan14RootReadCensus(t *testing.T) {
t.Fatal(err)
}
seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d"
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ // Census overlay region-ha-2b (region HA spec 2, plan 2b), loaded last.
+ // Its header says what each plan task added. The rows it adds are
+ // census ruling region-ha-2b's (platform-1e); its accepted-legacy rows
+ // move the seal.
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
new file mode 100644
index 000000000..bf5f2e1db
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -0,0 +1,7 @@
+# Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b.
+# Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands.
+@coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d
+@coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa
+internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
+internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
+internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go
index c17537ff6..9ef8010a4 100644
--- a/region_ha_boundary_test.go
+++ b/region_ha_boundary_test.go
@@ -30,6 +30,10 @@ var regionHACoreFiles = []string{
"internal/console/nocapacity.go",
"internal/hostops/chosen.go",
"internal/console/regioncapacity.go",
+ "internal/hostenv/hostenv.go",
+ "internal/hostenv/cli.go",
+ "internal/hostenv/write_linux.go",
+ "internal/hostenv/write_other.go",
}
// regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so
@@ -37,6 +41,7 @@ var regionHACoreFiles = []string{
var regionHAAdapterFiles = []string{
"internal/host/awsaddr.go",
"internal/console/awsregionmetric.go",
+ "internal/hostenv/awsssm.go",
}
// regionHAPackages are the packages spec 2 touches. Every S3 client built
@@ -49,6 +54,7 @@ var regionHAPackages = []string{
"internal/edgesfile",
"internal/host",
"internal/hostactors",
+ "internal/hostenv",
"internal/hostops",
"internal/lbhealth",
}