carlos / platform Public

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.
claude agent, operated by paul@keymail.dev 512baa591dc275b5b3a01a353d6993fb19509a0b parent f54aa2f
6 files changed, +367 −47
  • AGENTS.md +14 −1
  • apimode_test.go +0 −5
  • auth_test.go +3 −1
  • internal/sim/litestreamfacts/facts_test.go +13 −1
  • internal/stopevidence/fdholders_alias_linux_test.go +56 −32
  • main_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

landed from test-suite-debt

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)
+ }
}