harness-build: -tags harness endpoint file fallback and test sums key, no-op hooks in the release build
One tagged file (internal/s3compat/harness.go) is the deployment harness's only binary deviation (review B1). Without the tag the hooks are nil and harness_default_test.go pins the release behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+326
−5
harness/RESOURCES.md+7 −0harness/RUNLOG.md+47 −0internal/host/claimproduction_marker.go+3 −1internal/hostfence/writer.go+11 −2internal/publicrelease/feed.go+12 −1internal/s3compat/harness.go+81 −0internal/s3compat/harness_default_test.go+54 −0internal/s3compat/harness_test.go+71 −0internal/s3compat/s3compat.go+40 −1
diff --git a/harness/RESOURCES.md b/harness/RESOURCES.md| new file mode 100644 |
| index 000000000..d36d7161b |
| --- /dev/null |
| +++ b/harness/RESOURCES.md |
| @@ -0,0 +1,7 @@ |
| +# Harness AWS resources (dev account 640012953448, eu-west-1, profile kass) |
| + |
| +Every resource is tagged `purpose=carlos-harness` and `ttl=<UTC expiry>`. |
| +Appended as each is created. Teardown commands: harness/TEARDOWN.md. |
| + |
| +| Created (UTC) | Type | ID / name | Notes | |
| +|---|---|---|---| |
diff --git a/harness/RUNLOG.md b/harness/RUNLOG.md| new file mode 100644 |
| index 000000000..cc3609b8d |
| --- /dev/null |
| +++ b/harness/RUNLOG.md |
| @@ -0,0 +1,47 @@ |
| +# Harness phase 1 run log (steps 1 to 6) |
| + |
| +Build order: Fable's "Revised phase 1" in harness-spec-fable-review.md, steps 1 to 6. |
| +Each step's check passes before the next starts. Times are UTC. Commands run |
| +from DOCS-T1 unless they say "on host" (sent through real SSM) or "in <container>". |
| + |
| +## Step 1: the harness build |
| + |
| +Started 2026-10-20T00:39:52Z, worktree base 85bd6c0b (85bd6c0b). |
| + |
| +Change: one tagged file, `internal/s3compat/harness.go` (`//go:build harness`), |
| +plus no-op hooks in release code: |
| + |
| +- `s3compat.Endpoint()` falls back to `/etc/carlos/harness-endpoint` only when |
| + `harnessEndpoint` is set, which only the tagged file does. |
| +- `s3compat.HarnessS3Options()` / `HarnessSTSEndpoint()`: called by the claim marker |
| + client (`internal/host/claimproduction_marker.go`) and the fence writer |
| + (`internal/hostfence/writer.go`, S3 and STS). No-ops without the tag. |
| +- `publicrelease.HarnessSumsKey`: nil without the tag; the tagged file reads |
| + `/etc/carlos/harness-sums-pubkey`. |
| +- Both files fail closed (missing, group/world-writable or >4 KiB reads as ""). |
| +- `CARLOS_HARNESS_INFO=1` makes a harness binary print its resolution and exit |
| + (the review's "`carlos harness-info` or equivalent"; no new verb, so |
| + `docs/site/cli.md` is untouched). |
| + |
| +Check (all on DOCS-T1): |
| + |
| +``` |
| +$ go test ./internal/s3compat ./internal/publicrelease -count=1 |
| +ok internal/s3compat 0.006s ; ok internal/publicrelease 0.008s |
| +$ go test -tags harness ./internal/s3compat ./internal/publicrelease -count=1 |
| +ok internal/s3compat 0.006s ; ok internal/publicrelease 0.008s |
| +$ go test -count=1 -run 'SumsKey|ClaimsSums' . # default build |
| +ok github.com/carlosframework/platform 0.066s |
| +$ go test -tags harness -count=1 -run 'SumsKey|ClaimsSums' . # expected FAIL: the key is the deviation |
| +--- FAIL: TestClaimsSumsKeyIsTheCLIUpdateKey: publicrelease key "", cliUpdatePubKey "R+EO3q..." |
| +$ go vet (both builds), GOOS=darwin/windows build of internal/host + internal/hostfence: clean |
| +$ CARLOS_HARNESS_INFO=1 ./carlos-amd64-harness |
| +harness build: yes / endpoint: (empty) / sums key: (none)... sha256:e3b0c44298fc |
| +$ CARLOS_HARNESS_INFO=1 ./carlos-amd64-default version |
| +carlos dev # the default build ignores the variable |
| +``` |
| + |
| +linux/arm64 binaries: `GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -tags harness |
| +-ldflags "-s -w -X .../internal/version.Build=harness-<sha>"` for `.` and `./cmd/console`. |
| + |
| +**Step 1: GREEN** (2026-10-20T00:39:52Z). |
diff --git a/internal/host/claimproduction_marker.go b/internal/host/claimproduction_marker.go| index 76e34f185..a68887b10 100644 |
| --- a/internal/host/claimproduction_marker.go |
| +++ b/internal/host/claimproduction_marker.go |
| @@ -11,6 +11,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/rootaws" |
| + "github.com/carlosframework/platform/internal/s3compat" |
| ) |
| type productionClaimMarkers struct { |
| @@ -23,7 +24,8 @@ func newProductionClaimMarkers(ctx context.Context, bucket, region string) (Clai |
| if err != nil { |
| return nil, err |
| } |
| - return productionClaimMarkers{Client: s3.NewFromConfig(cfg), Bucket: bucket}, nil |
| + // HarnessS3Options is a no-op outside the deployment harness's build. |
| + return productionClaimMarkers{Client: s3.NewFromConfig(cfg, s3compat.HarnessS3Options()), Bucket: bucket}, nil |
| } |
| func (m productionClaimMarkers) PutMarker(ctx context.Context, bucket, key string, body []byte, expected string, check func(context.Context) error) (string, error) { |
| if m.Client == nil || bucket != m.Bucket || check == nil || !strings.HasPrefix(key, "instances/") || !strings.HasSuffix(key, "/generation.json") { |
diff --git a/internal/hostfence/writer.go b/internal/hostfence/writer.go| index 1b2372998..f98d66e23 100644 |
| --- a/internal/hostfence/writer.go |
| +++ b/internal/hostfence/writer.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/aws/aws-sdk-go-v2/service/sts" |
| "github.com/carlosframework/platform/internal/hostfloor" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/s3compat" |
| ) |
| type fenceCredentials struct { |
| @@ -58,10 +59,18 @@ func OpenRootFenceStore(ctx context.Context, bucket string) (release.Store, erro |
| return nil, err |
| } |
| cfg := aws.Config{Region: c.Region, Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(c.AccessKey, c.SecretKey, ""))} |
| - provider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), c.Role, func(o *stscreds.AssumeRoleOptions) { o.RoleSessionName = "carlos-root-fence" }) |
| + // The two s3compat.Harness* hooks are no-ops outside the deployment |
| + // harness's build: this writer talks only to AWS in every release build. |
| + provider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg, harnessSTS), c.Role, func(o *stscreds.AssumeRoleOptions) { o.RoleSessionName = "carlos-root-fence" }) |
| cfg.Credentials = aws.NewCredentialsCache(provider) |
| - return &release.S3Store{Client: s3.NewFromConfig(cfg), Bucket: c.Bucket}, nil |
| + return &release.S3Store{Client: s3.NewFromConfig(cfg, s3compat.HarnessS3Options()), Bucket: c.Bucket}, nil |
| } |
| +func harnessSTS(o *sts.Options) { |
| + if ep := s3compat.HarnessSTSEndpoint(); ep != "" { |
| + o.BaseEndpoint = &ep |
| + } |
| +} |
| + |
| func decodeFenceCredentials(r io.Reader, bucket string) (fenceCredentials, error) { |
| var c fenceCredentials |
| d := json.NewDecoder(io.LimitReader(r, 16385)) |
diff --git a/internal/publicrelease/feed.go b/internal/publicrelease/feed.go| index 65f1e5270..5df6adcf4 100644 |
| --- a/internal/publicrelease/feed.go |
| +++ b/internal/publicrelease/feed.go |
| @@ -29,13 +29,24 @@ type Feed struct { |
| // Default is the vendor feed. The strings are duplicated in update.go's |
| // package vars so update_test.go can repoint them; keep the two in step. |
| func Default() Feed { |
| - return Feed{ |
| + f := Feed{ |
| LatestURL: "https://apt.carlosframework.com/latest.txt", |
| DownloadBase: "https://github.com/carlosframework/releases/releases/download", |
| PubKey: "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=", |
| } |
| + if HarnessSumsKey != nil { |
| + f.PubKey = HarnessSumsKey() |
| + } |
| + return f |
| } |
| +// HarnessSumsKey is set only by the deployment harness's build |
| +// (internal/s3compat/harness.go, -tags harness), which replaces the vendor |
| +// key with the harness's test sums key. It is nil in every release build; |
| +// internal/s3compat/harness_default_test.go and claims_sums_key_test.go |
| +// assert the vendor key. |
| +var HarnessSumsKey func() string |
| + |
| var TagRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) |
| func AssetName(platform string) string { return "carlos-" + platform } |
diff --git a/internal/s3compat/harness.go b/internal/s3compat/harness.go| new file mode 100644 |
| index 000000000..ef5c36c86 |
| --- /dev/null |
| +++ b/internal/s3compat/harness.go |
| @@ -0,0 +1,81 @@ |
| +//go:build harness |
| + |
| +// This file is compiled only into the deployment harness's build |
| +// (`go build -tags harness`), never into a release. It is the harness's one |
| +// binary deviation, and every harness run report lists it as "endpoint |
| +// resolution and sums key: harness build" (docs/superpowers/specs/ |
| +// 2026-10-20-deployment-harness-design.md, review B1). |
| +// |
| +// It does two things: |
| +// |
| +// 1. Endpoint() falls back to /etc/carlos/harness-endpoint when |
| +// CARLOS_S3_ENDPOINT is empty. Root processes scrub AWS_ENDPOINT_URL_* |
| +// (rootaws.ScrubEnvironment) and systemd-run, incus exec and units carry |
| +// no harness variable, so a file every process can read is the one |
| +// mechanism that reaches all of them. HarnessS3Options and |
| +// HarnessSTSEndpoint then point the claim marker client and the fence |
| +// writer, which ignore CARLOS_S3_ENDPOINT in a release build, at the |
| +// same store. |
| +// 2. publicrelease.Default().PubKey becomes the harness's test sums key, |
| +// read from /etc/carlos/harness-sums-pubkey, so publish-identity can |
| +// verify a harness release's SHA256SUMS.txt. |
| +// |
| +// Both files fail closed: a missing, unreadable, or group- or world-writable |
| +// file reads as "", which leaves the client on AWS (unreachable from a |
| +// harness box) and makes every sums signature fail to verify. |
| +// |
| +// CARLOS_HARNESS_INFO=1 makes the binary print what it resolved and exit 0, |
| +// so a person can check a binary is a harness build without running a verb. |
| +package s3compat |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "os" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/publicrelease" |
| +) |
| + |
| +var ( |
| + harnessEndpointFile = "/etc/carlos/harness-endpoint" |
| + harnessSumsKeyFile = "/etc/carlos/harness-sums-pubkey" |
| +) |
| + |
| +func init() { |
| + harnessEndpoint = func() string { return readHarnessFile(harnessEndpointFile) } |
| + publicrelease.HarnessSumsKey = func() string { return readHarnessFile(harnessSumsKeyFile) } |
| + if os.Getenv("CARLOS_HARNESS_INFO") == "1" { |
| + fmt.Fprint(os.Stdout, HarnessInfo()) |
| + os.Exit(0) |
| + } |
| +} |
| + |
| +// HarnessInfo describes the harness build's resolution: the endpoint and a |
| +// prefix of the sums key with its sha256, never more of the key than that. |
| +func HarnessInfo() string { |
| + key := publicrelease.Default().PubKey |
| + sum := sha256.Sum256([]byte(key)) |
| + prefix := key |
| + if len(prefix) > 8 { |
| + prefix = prefix[:8] |
| + } |
| + if key == "" { |
| + prefix = "(none)" |
| + } |
| + return fmt.Sprintf("harness build: yes\nendpoint: %s\nsums key: %s... sha256:%s\n", |
| + Endpoint(), prefix, hex.EncodeToString(sum[:])[:12]) |
| +} |
| + |
| +func readHarnessFile(path string) string { |
| + st, err := os.Stat(path) |
| + if err != nil || !st.Mode().IsRegular() || st.Mode().Perm()&0o022 != 0 || st.Size() > 4096 { |
| + return "" |
| + } |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + return "" |
| + } |
| + return strings.TrimSpace(string(b)) |
| +} |
diff --git a/internal/s3compat/harness_default_test.go b/internal/s3compat/harness_default_test.go| new file mode 100644 |
| index 000000000..4428ebb5c |
| --- /dev/null |
| +++ b/internal/s3compat/harness_default_test.go |
| @@ -0,0 +1,54 @@ |
| +//go:build !harness |
| + |
| +package s3compat |
| + |
| +import ( |
| + "testing" |
| + |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/carlosframework/platform/internal/publicrelease" |
| +) |
| + |
| +// TestHarnessDefaultBuildHasNoFallback pins the release build: the |
| +// deployment harness's endpoint file and test sums key exist only under |
| +// -tags harness (harness.go). Without the tag there is no fallback, the |
| +// root clients' harness hooks change nothing, and the sums key is the |
| +// vendor's. |
| +func TestHarnessDefaultBuildHasNoFallback(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + if harnessEndpoint != nil { |
| + t.Fatal("release build has a harness endpoint fallback") |
| + } |
| + if got := Endpoint(); got != "" { |
| + t.Fatalf("Endpoint() = %q with the variable empty, want \"\"", got) |
| + } |
| + if got := HarnessSTSEndpoint(); got != "" { |
| + t.Fatalf("HarnessSTSEndpoint() = %q, want \"\"", got) |
| + } |
| + var o awss3.Options |
| + HarnessS3Options()(&o) |
| + if o.BaseEndpoint != nil || o.UsePathStyle { |
| + t.Fatal("HarnessS3Options changed a client in the release build") |
| + } |
| + if publicrelease.HarnessSumsKey != nil { |
| + t.Fatal("release build has a harness sums key hook") |
| + } |
| + if got := publicrelease.Default().PubKey; got != "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=" { |
| + t.Fatalf("sums key = %q, want the vendor key", got) |
| + } |
| +} |
| + |
| +// TestHarnessDefaultBuildRootClientsIgnoreTheVariable: the root hooks stay |
| +// no-ops even when CARLOS_S3_ENDPOINT is set, so the marker client and the |
| +// fence writer keep their release behaviour. |
| +func TestHarnessDefaultBuildRootClientsIgnoreTheVariable(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "http://203.0.113.10:9000") |
| + var o awss3.Options |
| + HarnessS3Options()(&o) |
| + if o.BaseEndpoint != nil { |
| + t.Fatal("HarnessS3Options honoured CARLOS_S3_ENDPOINT in the release build") |
| + } |
| + if HarnessSTSEndpoint() != "" { |
| + t.Fatal("HarnessSTSEndpoint honoured CARLOS_S3_ENDPOINT in the release build") |
| + } |
| +} |
diff --git a/internal/s3compat/harness_test.go b/internal/s3compat/harness_test.go| new file mode 100644 |
| index 000000000..a42f39cae |
| --- /dev/null |
| +++ b/internal/s3compat/harness_test.go |
| @@ -0,0 +1,71 @@ |
| +//go:build harness |
| + |
| +package s3compat |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/carlosframework/platform/internal/publicrelease" |
| +) |
| + |
| +func harnessFiles(t *testing.T, endpoint, key string, mode os.FileMode) { |
| + t.Helper() |
| + dir := t.TempDir() |
| + ep, sk := filepath.Join(dir, "endpoint"), filepath.Join(dir, "key") |
| + for p, v := range map[string]string{ep: endpoint, sk: key} { |
| + if err := os.WriteFile(p, []byte(v+"\n"), mode); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(p, mode); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + oe, ok := harnessEndpointFile, harnessSumsKeyFile |
| + harnessEndpointFile, harnessSumsKeyFile = ep, sk |
| + t.Cleanup(func() { harnessEndpointFile, harnessSumsKeyFile = oe, ok }) |
| +} |
| + |
| +func TestHarnessBuildFallsBackToFile(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + harnessFiles(t, "http://203.0.113.10:9000", "dGVzdC1zdW1zLWtleQ==", 0o644) |
| + if got := Endpoint(); got != "http://203.0.113.10:9000" { |
| + t.Fatalf("Endpoint() = %q, want the file's endpoint", got) |
| + } |
| + if got := HarnessSTSEndpoint(); got != "http://203.0.113.10:9000" { |
| + t.Fatalf("HarnessSTSEndpoint() = %q", got) |
| + } |
| + var o awss3.Options |
| + HarnessS3Options()(&o) |
| + if o.BaseEndpoint == nil || *o.BaseEndpoint != "http://203.0.113.10:9000" || !o.UsePathStyle { |
| + t.Fatalf("HarnessS3Options did not point the client at the harness store: %+v", o.BaseEndpoint) |
| + } |
| + if got := publicrelease.Default().PubKey; got != "dGVzdC1zdW1zLWtleQ==" { |
| + t.Fatalf("sums key = %q, want the harness test key", got) |
| + } |
| + if info := HarnessInfo(); !strings.Contains(info, "sums key: dGVzdC1z...") { |
| + t.Fatalf("HarnessInfo = %q", info) |
| + } |
| +} |
| + |
| +func TestHarnessBuildEnvironmentStillWins(t *testing.T) { |
| + harnessFiles(t, "http://203.0.113.10:9000", "k", 0o644) |
| + t.Setenv("CARLOS_S3_ENDPOINT", "http://other.example:9000") |
| + if got := Endpoint(); got != "http://other.example:9000" { |
| + t.Fatalf("Endpoint() = %q, want the variable", got) |
| + } |
| +} |
| + |
| +func TestHarnessBuildRefusesWritableFiles(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + harnessFiles(t, "http://203.0.113.10:9000", "k", 0o666) |
| + if got := Endpoint(); got != "" { |
| + t.Fatalf("Endpoint() = %q from a world-writable file, want \"\"", got) |
| + } |
| + if got := publicrelease.Default().PubKey; got != "" { |
| + t.Fatalf("sums key = %q from a world-writable file, want \"\"", got) |
| + } |
| +} |
diff --git a/internal/s3compat/s3compat.go b/internal/s3compat/s3compat.go| index 68995019f..e86c19872 100644 |
| --- a/internal/s3compat/s3compat.go |
| +++ b/internal/s3compat/s3compat.go |
| @@ -37,8 +37,47 @@ import ( |
| // Endpoint reports the CARLOS_S3_ENDPOINT override, or "" for stock AWS. Read |
| // per call rather than cached at init so a test — and a process that re-reads |
| // its config — sees the value it just set. |
| +// |
| +// In the harness build (-tags harness, harness.go) an empty variable falls back |
| +// to the endpoint the deployment harness writes on each of its boxes. Every |
| +// other build has no fallback: harnessEndpoint stays nil. |
| func Endpoint() string { |
| - return strings.TrimSpace(os.Getenv("CARLOS_S3_ENDPOINT")) |
| + if ep := strings.TrimSpace(os.Getenv("CARLOS_S3_ENDPOINT")); ep != "" { |
| + return ep |
| + } |
| + if harnessEndpoint != nil { |
| + return harnessEndpoint() |
| + } |
| + return "" |
| +} |
| + |
| +// harnessEndpoint is set only by harness.go, which is compiled only with |
| +// -tags harness. It is nil in every release build; harness_default_test.go |
| +// asserts that. |
| +var harnessEndpoint func() string |
| + |
| +// HarnessS3Options is for the two root S3 clients that deliberately ignore |
| +// CARLOS_S3_ENDPOINT (the claim generation-marker client and the fence |
| +// writer). In every build but the harness build it is a NO-OP, so those |
| +// clients keep talking to AWS exactly as before. In the harness build it |
| +// applies Options, pointing them at the harness's store like every other |
| +// client. |
| +func HarnessS3Options() func(*awss3.Options) { |
| + return func(o *awss3.Options) { |
| + if harnessEndpoint == nil { |
| + return |
| + } |
| + Options()(o) |
| + } |
| +} |
| + |
| +// HarnessSTSEndpoint is the STS base endpoint for the fence writer's |
| +// AssumeRole in the harness build, and "" (use AWS) in every other build. |
| +func HarnessSTSEndpoint() string { |
| + if harnessEndpoint == nil { |
| + return "" |
| + } |
| + return Endpoint() |
| } |
| // EndpointHost is the bare host of the override ("" for stock AWS), which is |