region HA 2b: host.env from the deployment's parameters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+1293
−1
approved_region_ha_2b_copy_test.go+1 −1go.mod+1 −0go.sum+2 −0internal/hostenv/awsssm.go+98 −0internal/hostenv/cli.go+115 −0internal/hostenv/hostenv.go+437 −0internal/hostenv/hostenv_test.go+365 −0internal/hostenv/write_linux.go+108 −0internal/hostenv/write_linux_test.go+116 −0internal/hostenv/write_other.go+11 −0internal/hostenv/write_other_test.go+15 −0internal/provision/plan14_root_reads_test.go+11 −0internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv+7 −0region_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", |
| } |