test-suite-debt
Prompts: <task-notification> <task-id>bx80ra4so</task-id> <tool-use-id>toolu_01Ya46uNxkKbYZb5UqWytP23</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-platform/c4a04fdc-6a78-43c2-8a77-eb5fbb115271/tasks/bx80ra4so.output</output-file> <status>completed</status> <summary>Background command "Run the Astra code review of the suite-debt branch" completed (exit code 0)</summary> </task-notification> <task-notification> <task-id>b9bjis4cj</task-id> <tool-use-id>toolu_01RCWkVVtQzo3xdPZ3Zh9R3S</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-platform/c4a04fdc-6a78-43c2-8a77-eb5fbb115271/tasks/b9bjis4cj.output</output-file> <status>completed</status> <summary>Background command "Run the second Astra review of the suite-debt branch" completed (exit code 0)</summary> </task-notification> Squashed from test-suite-debt into main.
6 files changed,
+367
−47
AGENTS.md+14 −1apimode_test.go+0 −5auth_test.go+3 −1internal/sim/litestreamfacts/facts_test.go+13 −1internal/stopevidence/fdholders_alias_linux_test.go+56 −32main_test.go+281 −7
CI passed — run details
CI log
=== gate ===
go: downloading github.com/aws/aws-sdk-go-v2 v1.44.0
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading golang.org/x/sys v0.46.0
go: downloading github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
go: downloading github.com/aws/aws-sdk-go-v2/config v1.32.6
go: downloading github.com/benbjohnson/litestream v0.5.14
go: downloading github.com/superfly/ltx v0.5.1
go: downloading github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
go: downloading golang.org/x/crypto v0.53.0
go: downloading github.com/oschwald/maxminddb-golang v1.13.1
go: downloading github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
go: downloading github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.6
go: downloading github.com/aws/smithy-go v1.28.1
go: downloading github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0
go: downloading github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
go: downloading github.com/pion/logging v0.2.4
go: downloading github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
go: downloading github.com/klauspost/compress v1.18.0
go: downloading github.com/pion/stun/v3 v3.0.1
go: downloading github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
go: downloading github.com/pion/turn/v4 v4.1.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
go: downloading golang.org/x/net v0.56.0
go: downloading github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.8
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading golang.org/x/sync v0.21.0
go: downloading github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18
go: downloading github.com/pion/dtls/v3 v3.0.7
go: downloading github.com/pion/transport/v3 v3.0.8
go: downloading github.com/pion/randutil v0.1.0
go: downloading github.com/pion/transport/v4 v4.0.1
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/prometheus/common v0.45.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading golang.org/x/text v0.38.0
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
go: downloading golang.org/x/tools v0.47.0
go: downloading golang.org/x/mod v0.37.0
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 345.353s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.826s
ok github.com/carlosframework/platform/cmd/moveprobe 93.469s
ok github.com/carlosframework/platform/cmd/zddprobe 0.712s
ok github.com/carlosframework/platform/internal/activator 251.613s
ok github.com/carlosframework/platform/internal/agentsock 0.018s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.507s
ok github.com/carlosframework/platform/internal/apperr 0.073s
ok github.com/carlosframework/platform/internal/applog 0.143s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.692s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.013s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.710s
ok github.com/carlosframework/platform/internal/changelog 0.332s
ok github.com/carlosframework/platform/internal/claimactivation 0.820s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 440.416s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.978s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.007s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.007s
ok github.com/carlosframework/platform/internal/domains 2.382s
ok github.com/carlosframework/platform/internal/doorkey 0.009s
ok github.com/carlosframework/platform/internal/economics 0.008s
ok github.com/carlosframework/platform/internal/edge 45.005s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.135s
ok github.com/carlosframework/platform/internal/gate 1.656s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.018s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/historyreadback 0.165s
ok github.com/carlosframework/platform/internal/host 197.998s
ok github.com/carlosframework/platform/internal/hostactors 6.614s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.005s
ok github.com/carlosframework/platform/internal/hostbox 2.775s
ok github.com/carlosframework/platform/internal/hostclaim 0.593s
ok github.com/carlosframework/platform/internal/hostenv 0.007s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 5.230s
ok github.com/carlosframework/platform/internal/hostfence 0.043s
ok github.com/carlosframework/platform/internal/hostfiles 10.976s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 1.043s
ok github.com/carlosframework/platform/internal/hostops 1.662s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.488s
ok github.com/carlosframework/platform/internal/hostresolve 0.140s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 5.265s
ok github.com/carlosframework/platform/internal/instanceexec 2.753s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/instancekey 0.003s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 1.632s
ok github.com/carlosframework/platform/internal/lbhealth 0.002s
ok github.com/carlosframework/platform/internal/lease 9.965s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.398s
ok github.com/carlosframework/platform/internal/ledger 0.012s
ok github.com/carlosframework/platform/internal/mail 0.194s
ok github.com/carlosframework/platform/internal/maintenance 0.081s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.698s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.084s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.921s
ok github.com/carlosframework/platform/internal/moves/archive 0.858s
ok github.com/carlosframework/platform/internal/objstore 0.225s
ok github.com/carlosframework/platform/internal/ops 0.186s
ok github.com/carlosframework/platform/internal/park 0.228s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 94.419s
ok github.com/carlosframework/platform/internal/publicrelease 0.014s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.949s
ok github.com/carlosframework/platform/internal/relay 7.272s
ok github.com/carlosframework/platform/internal/release 27.319s
ok github.com/carlosframework/platform/internal/rollout 3.700s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.062s
ok github.com/carlosframework/platform/internal/rootstate 0.027s
ok github.com/carlosframework/platform/internal/runs 0.574s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.578s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 12.161s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.223s
ok github.com/carlosframework/platform/internal/status 0.073s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.310s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.008s
ok github.com/carlosframework/platform/internal/unixdial 0.005s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.006s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.607s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49807522 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/21 13:12:18 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E08D989C88E8CA, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.023s
minio contract: PASS
diff --git a/AGENTS.md b/AGENTS.md| index cedb4c57c..8b81caa89 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -296,7 +296,20 @@ anything. |
| **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for |
| `.carlos/config`. The fallback is the operator's real login and default |
| console; a logged-in laptop running `go test ./...` minted 40+ real accounts |
| - on the production console before this was isolated. |
| + on the production console before this was isolated. A `t.TempDir()` is |
| + scratch in that sense only because the root package's `TestMain` |
| + (`isolateTempRoot`, main_test.go) checks both temp roots, TMPDIR and |
| + GOTMPDIR (`t.TempDir()` uses GOTMPDIR when it is set), and moves whichever |
| + has a `.carlos/config` above it, which is the case wherever one is under a |
| + logged-in home (#226, #258). The move is to a new |
| + `/var/tmp/carlos-test-*` directory, named on stderr and removed when the |
| + run ends. A run that is aborted (`log.Fatal`, a panic, a timeout) leaves |
| + that one directory behind: remove it by the name that was printed, never |
| + by pattern. `/tmp` is never used, and with `/var/tmp` unavailable the |
| + binary stops and says which variable to set. `TestMain` also drops an |
| + exported `CARLOS_CONFIG` and `CARLOS_DESTINATION`, which no shared helper |
| + pins. A test that leaves its cwd at the checkout is not covered by any of |
| + that. |
| - **A sidecar is a member that nothing can dial.** `Kind == "sidecar"` rows |
| carry no socket and no addr, and are refused by the edge ON KIND — not |
| merely unmatched by it — before dispatch ever looks at shape. That refusal |
diff --git a/apimode_test.go b/apimode_test.go| index d3296c017..33abb4f9d 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -1054,11 +1054,6 @@ func TestShipVerbAPIMode(t *testing.T) { |
| useTempCredentials(t) |
| clearDeploymentEnv(t) |
| checkNoRegistry := requireNoRegistryTouch(t) |
| - // The working directory useTempCredentials picks is under $TMPDIR, which |
| - // can sit inside the operator's real home; the .carlos/config walk goes |
| - // up from it and would find the real ~/.carlos/config. Name a config |
| - // file of the test's own (missing, so no opinion) and skip discovery. |
| - t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "config")) |
| var reqCount int32 |
| mux := http.NewServeMux() |
diff --git a/auth_test.go b/auth_test.go| index 5a51b4955..b15d338bb 100644 |
| --- a/auth_test.go |
| +++ b/auth_test.go |
| @@ -17,7 +17,9 @@ import ( |
| // the credentials file, $HOME, the working directory, CARLOS_CONSOLE — at |
| // scratch space for the length of one test. Config resolution (config.go) |
| // consults all four; a developer's real ~/.carlos/config must never change |
| -// a test's answer. |
| +// a test's answer. The working directory is only scratch space because |
| +// TestMain (main_test.go isolateTempRoot) keeps every t.TempDir() clear of |
| +// a .carlos/config above it: discovery walks up from here. |
| func useTempCredentials(t *testing.T) string { |
| t.Helper() |
| home := t.TempDir() |
diff --git a/internal/sim/litestreamfacts/facts_test.go b/internal/sim/litestreamfacts/facts_test.go| index 2f76c6e5c..f66168944 100644 |
| --- a/internal/sim/litestreamfacts/facts_test.go |
| +++ b/internal/sim/litestreamfacts/facts_test.go |
| @@ -817,7 +817,19 @@ func TestGate_AnchoredLineageThroughStore(t *testing.T) { |
| for _, lvl := range []int{1, 2, 3} { |
| waitFor(t, fmt.Sprintf("a level-%d compaction", lvl), 5*time.Second, func() bool { return len(files(t, gen, lvl)) > 0 }) |
| } |
| - waitFor(t, "snapshot retention", 5*time.Second, func() bool { return len(files(t, gen, 9)) == 1 }) |
| + // Retention is awaited at the state that lasts: one snapshot, at the |
| + // round's final position. One snapshot alone can be a moment in |
| + // passing. The monitor snapshots every interval and keeps the previous |
| + // snapshot while it is younger than the retention, so a tick that runs |
| + // 100 ms late removes the previous one and leaves a single snapshot |
| + // that is behind the position; the next tick adds the final snapshot |
| + // beside it, and a round that had stopped waiting closed the store |
| + // with two (#266). Once the only snapshot is at the final position a |
| + // later tick can only rewrite that same file. |
| + waitFor(t, "snapshot retention", 5*time.Second, func() bool { |
| + l9 := files(t, gen, 9) |
| + return len(l9) == 1 && int(l9[0].MaxTXID) == pos |
| + }) |
| waitFor(t, "level-0 expiry", 5*time.Second, func() bool { return len(files(t, gen, 0)) < l0Before+8 }) |
| if err := store.Close(ctx); err != nil { |
| t.Fatal(err) |
diff --git a/internal/stopevidence/fdholders_alias_linux_test.go b/internal/stopevidence/fdholders_alias_linux_test.go| index b0ee488ce..002a827d1 100644 |
| --- a/internal/stopevidence/fdholders_alias_linux_test.go |
| +++ b/internal/stopevidence/fdholders_alias_linux_test.go |
| @@ -3,8 +3,10 @@ |
| package stopevidence |
| import ( |
| + "bufio" |
| "context" |
| "errors" |
| + "fmt" |
| "io/fs" |
| "os" |
| "os/exec" |
| @@ -17,21 +19,62 @@ import ( |
| "time" |
| ) |
| -// holdOpen starts a child that holds path open as descriptor 3 and sleeps. |
| +// heldAndQuiet ends every holder's script: the shell says it has finished |
| +// starting, then blocks in its own read on a pipe nothing is written to. |
| +// |
| +// A scan must not meet a holder that is still starting. A process that has |
| +// just been exec'd opens and closes the linker cache, libc and the locale |
| +// files in turn, each on the first free descriptor. A scan that reads one of |
| +// them between its close and the next open sees a descriptor whose stat says |
| +// gone and whose entry is there, which is ErrIncomplete by design |
| +// (TestFDHoldersStatGoneWhileTheEntryRemains). The holders here used to be |
| +// `sleep 60`, scanned as soon as it was started, and the hard-link test |
| +// failed that way once under load (#274). |
| +const heldAndQuiet = "echo ready && read wait" |
| + |
| +// startHolder starts cmd, a shell whose script ends in heldAndQuiet, and |
| +// returns its pid once it has said ready. An error means it never did. |
| +func startHolder(t *testing.T, cmd *exec.Cmd) (int, error) { |
| + t.Helper() |
| + // Wait closes this end, and until then the child's read has nothing. |
| + if _, err := cmd.StdinPipe(); err != nil { |
| + t.Fatal(err) |
| + } |
| + r, w, err := os.Pipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + cmd.Stdout = w |
| + err = cmd.Start() |
| + w.Close() |
| + if err != nil { |
| + return 0, err |
| + } |
| + t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) |
| + r.SetReadDeadline(time.Now().Add(10 * time.Second)) |
| + if line, err := bufio.NewReader(r).ReadString('\n'); line != "ready\n" { |
| + return 0, fmt.Errorf("the holder said %q before it stopped: %v", line, err) |
| + } |
| + return cmd.Process.Pid, nil |
| +} |
| + |
| +// holdOpen starts a child that holds path open as descriptor 3 and opens |
| +// nothing more. |
| func holdOpen(t *testing.T, path string) int { |
| t.Helper() |
| f, err := os.Open(path) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - cmd := exec.Command("sleep", "60") |
| + defer f.Close() |
| + cmd := exec.Command("sh", "-c", heldAndQuiet) |
| cmd.ExtraFiles = []*os.File{f} |
| - if err := cmd.Start(); err != nil { |
| + pid, err := startHolder(t, cmd) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| - f.Close() |
| - t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) |
| - return cmd.Process.Pid |
| + return pid |
| } |
| // projectPIDs is a scan root holding only these processes' real /proc |
| @@ -124,35 +167,16 @@ func TestFDIdentityFindsAHolderInAnotherMountNamespace(t *testing.T) { |
| t.Fatal(err) |
| } |
| aliased := filepath.Join(alias, filepath.Base(db)) |
| + // The shell opens the database on descriptor 3 itself, after the mount, |
| + // and says ready only then: until that point descriptor 3 can be some |
| + // other file (the dynamic loader's libc, while unshare or sh starts). |
| cmd := exec.Command("unshare", "--user", "--map-root-user", "--mount", "sh", "-c", |
| - `mount --bind "$1" "$2" && exec sleep 60 3<"$3"`, "sh", filepath.Join(dir, "real"), alias, aliased) |
| - if err := cmd.Start(); err != nil { |
| - t.Skip("cannot start unshare:", err) |
| - } |
| - t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) |
| - pid := cmd.Process.Pid |
| - fd3 := filepath.Join("/proc", strconv.Itoa(pid), "fd", "3") |
| - // Until sh has run the mount and exec'd sleep, fd 3 can be some other |
| - // file (the dynamic loader's libc, while unshare or sh starts), so wait |
| - // for fd 3 to be the database itself, by identity: stat through the |
| - // magic link reaches the file the descriptor holds in any namespace. |
| - want, err := os.Stat(db) |
| + `mount --bind "$1" "$2" && exec 3<"$3" && `+heldAndQuiet, "sh", filepath.Join(dir, "real"), alias, aliased) |
| + pid, err := startHolder(t, cmd) |
| if err != nil { |
| - t.Fatal(err) |
| - } |
| - var target string |
| - held := false |
| - for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { |
| - if fi, err := os.Stat(fd3); err == nil && os.SameFile(fi, want) { |
| - target, _ = os.Readlink(fd3) |
| - held = true |
| - break |
| - } |
| - } |
| - if !held { |
| - t.Skip("the namespace holder never came up holding the database (unprivileged user namespaces refused?)") |
| + t.Skip("the namespace holder never came up holding the database (unprivileged user namespaces refused?):", err) |
| } |
| - if target != aliased { |
| + if target, _ := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "fd", "3")); target != aliased { |
| t.Logf("descriptor text %q (expected the bind path %q)", target, aliased) |
| } |
| root := projectPIDs(t, pid) |
diff --git a/main_test.go b/main_test.go| index b8711d2d9..c37400943 100644 |
| --- a/main_test.go |
| +++ b/main_test.go |
| @@ -1,16 +1,290 @@ |
| package main |
| import ( |
| + "errors" |
| + "fmt" |
| "os" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| "testing" |
| ) |
| -// TestMain keeps an exported deploy token out of the root package's tests. |
| -// resolveMode reads CARLOS_SERVICE_TOKEN, and a CI shell running `go test` |
| -// may well export one. In-process tests that call run() or a verb directly |
| -// would then inherit it. Tests that want a token set it with t.Setenv, which |
| -// still works after this Unsetenv. |
| +// TestMain keeps the operator's own state out of the root package's tests. |
| +// |
| +// Three exported variables are dropped before any test runs. No shared |
| +// helper pins all three, and each steers a test that calls run() or a verb |
| +// directly, and every child that inherits os.Environ(). A test that wants |
| +// one sets it with t.Setenv, which still works after an Unsetenv. |
| +// |
| +// - CARLOS_SERVICE_TOKEN: resolveMode reads it, and a CI shell running |
| +// `go test` may well export one. |
| +// - CARLOS_CONFIG: it names the one config file read and switches |
| +// discovery off, so with it exported every test that relies on its |
| +// scratch directory holding no config reads the developer's file |
| +// instead, and ships to the console it names. |
| +// - CARLOS_DESTINATION: it selects a block in whatever config a test |
| +// wrote, and a config that does not define it is an error. |
| +// |
| +// CARLOS_CONSOLE and CARLOS_CREDENTIALS stay: the shared helpers pin both. |
| +// HOME stays too, because the tests' own `go build` needs the real one. |
| +// |
| +// The operator's .carlos/config: see isolateTempRoot. The binary refuses to |
| +// run rather than work under one. |
| func TestMain(m *testing.M) { |
| - os.Unsetenv("CARLOS_SERVICE_TOKEN") |
| - os.Exit(m.Run()) |
| + for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} { |
| + os.Unsetenv(name) |
| + } |
| + // /var/tmp alone. /tmp is not offered: the tests build binaries into |
| + // the temp root, and on a machine where /tmp is shared memory the move |
| + // would put them there without anyone having asked. |
| + moved, cleanup, err := isolateTempRoot([]string{"/var/tmp"}) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + if moved != "" { |
| + fmt.Fprintln(os.Stderr, moved) |
| + } |
| + code := m.Run() |
| + cleanup() |
| + os.Exit(code) |
| +} |
| + |
| +// tempRootPrefix names the directory isolateTempRoot makes. Its cleanup runs |
| +// only when the tests return: a log.Fatal, a panic or a timeout leaves that |
| +// one directory behind, and the line TestMain prints says where it is. |
| +const tempRootPrefix = "carlos-test-" |
| + |
| +// isolateTempRoot makes sure no .carlos/config sits above the directories |
| +// the tests' scratch space comes from, before any test asks for one. |
| +// |
| +// The tests pin HOME, but the CLI's config discovery (config.go |
| +// walkConfigFiles) walks up from the working directory, and that directory |
| +// is a t.TempDir() for every test that uses useTempCredentials or runs the |
| +// binary from a scratch cmd.Dir. Where the temp root is inside a logged-in |
| +// operator's home (TMPDIR=~/.cache/tmp/..., the default in a sandboxed agent |
| +// session) the walk reached the real ~/.carlos/config: two dozen tests |
| +// failed with "not logged in" to the operator's console, and one offered a |
| +// re-login to it and waited ten minutes (#226, #258). Pinning CARLOS_CONFIG |
| +// in the shared helpers is not the fix, because it switches discovery off |
| +// for the tests that write a .carlos/config of their own. |
| +// |
| +// There are two roots. os.TempDir() is TMPDIR. t.TempDir() is GOTMPDIR |
| +// when that is set and os.TempDir() only when it is not (testing.go |
| +// makeTempDir: os.MkdirTemp(os.Getenv("GOTMPDIR"), pattern)), so a clear |
| +// TMPDIR says nothing about a GOTMPDIR under a home. |
| +// |
| +// Roots with nothing above them are left alone, which is every CI run. |
| +// Otherwise the tests get one fresh directory under the first of bases that |
| +// is clear, and each variable whose root was not clear is pointed at it, so |
| +// child processes follow. An explicit GOTMPDIR is pointed there rather than |
| +// cleared: cleared, t.TempDir() would fall back to TMPDIR, which may be the |
| +// very place the developer set GOTMPDIR to keep the tests' binaries off. |
| +// moved says where, and the returned cleanup removes it. When no directory |
| +// can be made the variables are put back, the error names the ones to set, |
| +// and TestMain stops. |
| +func isolateTempRoot(bases []string) (moved string, cleanup func(), err error) { |
| + var why, names []string |
| + if p := configAbove(os.TempDir()); p != "" { |
| + why = append(why, fmt.Sprintf("%s is above the temp directory %s", p, os.TempDir())) |
| + names = append(names, "TMPDIR") |
| + } |
| + if root := os.Getenv("GOTMPDIR"); root != "" { |
| + if p := configAbove(root); p != "" { |
| + why = append(why, fmt.Sprintf("%s is above GOTMPDIR %s", p, root)) |
| + names = append(names, "GOTMPDIR") |
| + } |
| + } |
| + if len(names) == 0 { |
| + return "", func() {}, nil |
| + } |
| + type was struct { |
| + value string |
| + set bool |
| + } |
| + before := map[string]was{} |
| + for _, name := range names { |
| + v, ok := os.LookupEnv(name) |
| + before[name] = was{v, ok} |
| + } |
| + for _, base := range bases { |
| + dir, err := os.MkdirTemp(base, tempRootPrefix) |
| + if err != nil { |
| + continue |
| + } |
| + for _, name := range names { |
| + os.Setenv(name, dir) |
| + } |
| + // Asked of both roots again rather than assumed: the base may sit |
| + // under a config too, and not every platform reads TMPDIR. |
| + if configAbove(os.TempDir()) == "" && configAbove(os.Getenv("GOTMPDIR")) == "" { |
| + moved = fmt.Sprintf("carlos tests: %s, so the tests work in %s instead (an aborted run leaves it behind)", strings.Join(why, " and "), dir) |
| + return moved, func() { os.RemoveAll(dir) }, nil |
| + } |
| + os.Remove(dir) |
| + for name, w := range before { |
| + if w.set { |
| + os.Setenv(name, w.value) |
| + } else { |
| + os.Unsetenv(name) |
| + } |
| + } |
| + } |
| + return "", nil, fmt.Errorf("carlos tests: %s, and the CLI's config walk would read it. No directory could be made under %s to work in instead: set %s to a directory with no .carlos/config above it", |
| + strings.Join(why, " and "), strings.Join(bases, " or "), strings.Join(names, " and ")) |
| +} |
| + |
| +// configAbove is the first .carlos/config the CLI's discovery would read |
| +// walking up from dir, "" when there is none or dir is "". The walk is over |
| +// the path as written, which is what discovery follows after a t.Chdir, and |
| +// over the path with its symlinks resolved, which is what it follows when |
| +// PWD is unset. Anything but "does not exist" counts as there: discovery |
| +// fails on a config it cannot read. |
| +func configAbove(dir string) string { |
| + if dir == "" { |
| + return "" |
| + } |
| + if abs, err := filepath.Abs(dir); err == nil { |
| + dir = abs |
| + } |
| + starts := []string{dir} |
| + if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir { |
| + starts = append(starts, resolved) |
| + } |
| + for _, d := range starts { |
| + for { |
| + path := filepath.Join(d, ".carlos", "config") |
| + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { |
| + return path |
| + } |
| + parent := filepath.Dir(d) |
| + if parent == d { |
| + break |
| + } |
| + d = parent |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// TestMain's own promises, read back from inside a test: neither root has a |
| +// config above it, and none of the variables it drops came through. |
| +func TestMainLeavesNoOperatorState(t *testing.T) { |
| + for _, root := range []string{os.TempDir(), os.Getenv("GOTMPDIR"), t.TempDir()} { |
| + if p := configAbove(root); p != "" { |
| + t.Errorf("TestMain let the tests run with %s above %s", p, root) |
| + } |
| + } |
| + for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} { |
| + if v, ok := os.LookupEnv(name); ok { |
| + t.Errorf("%s=%q reached the tests", name, v) |
| + } |
| + } |
| +} |
| + |
| +// The guard, against a tree of its own. A root under a config moves to the |
| +// first base that is clear: TMPDIR, GOTMPDIR or both, whichever was under |
| +// it, and only those. The cleanup removes the directory and nothing else. |
| +// With no clear base the binary is told to stop and what to set. |
| +func TestIsolateTempRoot(t *testing.T) { |
| + if runtime.GOOS == "windows" { |
| + t.Skip("os.TempDir does not read TMPDIR on windows, so there is no moving it") |
| + } |
| + home := t.TempDir() |
| + planted := writeConfig(t, home, `{"console": "https://operator.example"}`) |
| + under := filepath.Join(home, "cache", "tmp") |
| + alsoUnder := filepath.Join(home, "var") |
| + clear, otherClear := t.TempDir(), t.TempDir() |
| + for _, d := range []string{under, alsoUnder} { |
| + if err := os.MkdirAll(d, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if got := configAbove(under); got != planted { |
| + t.Fatalf("configAbove(%s) = %q, want %s", under, got, planted) |
| + } |
| + bases := []string{filepath.Join(home, "missing"), alsoUnder, clear} |
| + |
| + for _, c := range []struct { |
| + name string |
| + tmpdir, gotmp string // as the run starts; "" is unset |
| + moveTmp bool |
| + moveGo bool |
| + }{ |
| + {"TMPDIR under a config, no GOTMPDIR", under, "", true, false}, |
| + {"TMPDIR under a config, GOTMPDIR clear", under, otherClear, true, false}, |
| + {"GOTMPDIR under a config, TMPDIR clear", otherClear, under, false, true}, |
| + {"both under a config", under, alsoUnder, true, true}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + t.Setenv("TMPDIR", c.tmpdir) |
| + t.Setenv("GOTMPDIR", c.gotmp) |
| + said, cleanup, err := isolateTempRoot(bases) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + made, _ := os.ReadDir(clear) |
| + if len(made) != 1 || !strings.HasPrefix(made[0].Name(), tempRootPrefix) { |
| + t.Fatalf("the clear base holds %v, want one %s directory", made, tempRootPrefix) |
| + } |
| + moved := filepath.Join(clear, made[0].Name()) |
| + wantTmp, wantGo := c.tmpdir, c.gotmp |
| + if c.moveTmp { |
| + wantTmp = moved |
| + } |
| + if c.moveGo { |
| + wantGo = moved |
| + } |
| + if os.TempDir() != wantTmp || os.Getenv("GOTMPDIR") != wantGo { |
| + t.Fatalf("TMPDIR=%s GOTMPDIR=%s, want %s and %s", os.TempDir(), os.Getenv("GOTMPDIR"), wantTmp, wantGo) |
| + } |
| + if !strings.Contains(said, planted) || !strings.Contains(said, moved) || |
| + strings.Contains(said, "above GOTMPDIR") != c.moveGo || strings.Contains(said, "above the temp directory") != c.moveTmp { |
| + t.Fatalf("said %q, want %s, %s and the roots that moved", said, planted, moved) |
| + } |
| + // What the guard is for: a test's own temp directory is clear now. |
| + t.Run("a test's temp directory", func(t *testing.T) { |
| + if p := configAbove(t.TempDir()); p != "" { |
| + t.Fatalf("t.TempDir() is still under %s", p) |
| + } |
| + }) |
| + if left, _ := os.ReadDir(alsoUnder); len(left) != 0 { |
| + t.Fatalf("the refused base kept %v", left) |
| + } |
| + cleanup() |
| + if _, err := os.Stat(moved); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the cleanup left %s (%v)", moved, err) |
| + } |
| + if _, err := os.Stat(clear); err != nil { |
| + t.Fatalf("the cleanup took the base with it: %v", err) |
| + } |
| + }) |
| + } |
| + |
| + // No clear base: an error naming the config and each variable to set, |
| + // with both variables as they were. |
| + t.Setenv("TMPDIR", otherClear) |
| + t.Setenv("GOTMPDIR", under) |
| + _, _, err := isolateTempRoot([]string{alsoUnder}) |
| + if err == nil || !strings.Contains(err.Error(), planted) || !strings.Contains(err.Error(), "set GOTMPDIR to") { |
| + t.Fatalf("no clear base = %v, want an error naming %s and GOTMPDIR", err, planted) |
| + } |
| + if os.TempDir() != otherClear || os.Getenv("GOTMPDIR") != under { |
| + t.Fatalf("a refusal left TMPDIR=%s GOTMPDIR=%s", os.TempDir(), os.Getenv("GOTMPDIR")) |
| + } |
| + t.Setenv("TMPDIR", under) |
| + if _, _, err := isolateTempRoot([]string{alsoUnder}); err == nil || !strings.Contains(err.Error(), "set TMPDIR and GOTMPDIR to") { |
| + t.Fatalf("no clear base, both roots = %v, want both variables named", err) |
| + } |
| + if left, _ := os.ReadDir(alsoUnder); len(left) != 0 { |
| + t.Fatalf("the refused base kept %v", left) |
| + } |
| + |
| + // Clear roots are not moved at all. |
| + t.Setenv("TMPDIR", clear) |
| + t.Setenv("GOTMPDIR", otherClear) |
| + if said, _, err := isolateTempRoot([]string{filepath.Join(home, "missing")}); err != nil || said != "" || os.TempDir() != clear || os.Getenv("GOTMPDIR") != otherClear { |
| + t.Fatalf("clear roots = %s, %s, %q, %v; want them left alone", os.TempDir(), os.Getenv("GOTMPDIR"), said, err) |
| + } |
| } |