fix-empty-app-key
Squashed from fix-empty-app-key into main.
26 files changed,
+811
−66
docs/superpowers/notes/2026-10-16-empty-app-key-cleanup.bash+145 −0internal/analytics/countries_test.go+2 −2internal/analytics/day.go+26 −5internal/analytics/day_test.go+15 −3internal/analytics/keyrefusal_test.go+49 −0internal/analytics/read.go+10 −2internal/apperr/keyrefusal_test.go+31 −0internal/apperr/read.go+11 −4internal/apperr/read_test.go+14 −2internal/applog/chunk.go+63 −12internal/applog/chunk_test.go+19 −7internal/applog/keyrefusal_test.go+84 −0internal/applog/read.go+5 −1internal/applog/read_test.go+8 −8internal/console/apierrors_test.go+3 −3internal/console/apilogs_test.go+15 −3internal/edge/fleetapi_e2e_test.go+13 −3internal/host/applessroute_test.go+209 −0internal/host/errship.go+6 −1internal/host/logship.go+35 −2internal/host/logship_test.go+15 −3internal/host/usage_loop.go+8 −1internal/provision/plan14_root_reads_test.go+8 −0internal/provision/plan9_b8_census_test.go+6 −3internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv+7 −0logs_e2e_test.go+4 −1
CI passed — run details
CI log
=== gate ===
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 ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 289.961s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.638s
ok github.com/carlosframework/platform/cmd/moveprobe 93.177s
ok github.com/carlosframework/platform/cmd/zddprobe 0.709s
ok github.com/carlosframework/platform/internal/activator 248.440s
ok github.com/carlosframework/platform/internal/agentsock 0.014s
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.439s
ok github.com/carlosframework/platform/internal/apperr 0.068s
ok github.com/carlosframework/platform/internal/applog 0.125s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.682s
? 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.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.701s
ok github.com/carlosframework/platform/internal/changelog 0.299s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 388.597s
? 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.947s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.002s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.382s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.711s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.130s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.019s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/host 162.152s
ok github.com/carlosframework/platform/internal/hostactors 0.410s
ok github.com/carlosframework/platform/internal/hostbox 2.499s
ok github.com/carlosframework/platform/internal/hostclaim 0.504s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 4.723s
ok github.com/carlosframework/platform/internal/hostfence 0.038s
ok github.com/carlosframework/platform/internal/hostfiles 10.529s
ok github.com/carlosframework/platform/internal/hostfloor 0.012s
ok github.com/carlosframework/platform/internal/hostguard 0.993s
ok github.com/carlosframework/platform/internal/hostops 1.555s
? 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.459s
ok github.com/carlosframework/platform/internal/hostresolve 0.131s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.003s
ok github.com/carlosframework/platform/internal/instancectl 4.162s
ok github.com/carlosframework/platform/internal/instanceexec 2.723s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/instancekey 0.005s
ok github.com/carlosframework/platform/internal/instanceupdates 0.008s
ok github.com/carlosframework/platform/internal/layout 1.481s
ok github.com/carlosframework/platform/internal/lease 9.786s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.006s
ok github.com/carlosframework/platform/internal/ledger 0.022s
ok github.com/carlosframework/platform/internal/mail 0.164s
ok github.com/carlosframework/platform/internal/maintenance 0.082s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.578s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.072s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 20.862s
ok github.com/carlosframework/platform/internal/moves/archive 0.777s
ok github.com/carlosframework/platform/internal/objstore 0.174s
ok github.com/carlosframework/platform/internal/ops 0.174s
ok github.com/carlosframework/platform/internal/park 0.188s
ok github.com/carlosframework/platform/internal/privatefile 0.006s
ok github.com/carlosframework/platform/internal/provision 15.978s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.909s
ok github.com/carlosframework/platform/internal/relay 7.233s
ok github.com/carlosframework/platform/internal/release 19.328s
ok github.com/carlosframework/platform/internal/rollout 3.657s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/runs 0.571s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.472s
ok github.com/carlosframework/platform/internal/seeds 0.010s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.024s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.682s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.192s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.006s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.548s
=== 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: 47055010 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.022s
=== RUN TestT4StorageProbeMinio
2026/10/16 13:51:07 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF06D0162FF951, 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.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/docs/superpowers/notes/2026-10-16-empty-app-key-cleanup.bash b/docs/superpowers/notes/2026-10-16-empty-app-key-cleanup.bash| new file mode 100755 |
| index 000000000..b0f880b0c |
| --- /dev/null |
| +++ b/docs/superpowers/notes/2026-10-16-empty-app-key-cleanup.bash |
| @@ -0,0 +1,145 @@ |
| +#!/usr/bin/env bash |
| +# One-off cleanup for the empty-app errors chunks in the dev deployment bucket |
| +# (branch fix-empty-app-key). Run by hand, by Paul, from a laptop. Nothing in |
| +# the platform runs this, and it never runs on a box. |
| +# |
| +# What it cleans: objects under exactly apps/bab// in carlos-dev-640012953448 |
| +# (eu-west-1). An app-less route on leo-climbing (proof.dev.carlosframework.com: |
| +# account bab, no app, an Addr upstream nobody listens on) had its refused 502s |
| +# filed under (bab, "") and shipped as apps/bab//errors/leo-climbing/... . |
| +# hostops.Apps refuses to walk a bucket holding that prefix. The writer fix |
| +# stops new ones, but the box's pruner no longer touches the prefix either, so |
| +# the ones already there stay until they are deleted. |
| +# |
| +# Usage: |
| +# 2026-10-16-empty-app-key-cleanup.bash dry run: list and count, delete nothing |
| +# 2026-10-16-empty-app-key-cleanup.bash --apply delete each current object by exact key |
| +# 2026-10-16-empty-app-key-cleanup.bash --apply --versions also delete every version and delete marker under the prefix, by key and version id |
| +# |
| +# The bucket is versioned. --apply alone deletes each current object with one |
| +# delete-object call per key, which leaves a delete marker: reversible, and |
| +# enough for hostops.Apps, which lists current objects only. --versions |
| +# deletes each listed version and delete marker by its version id, one call |
| +# each. That is permanent. |
| +# |
| +# Safety: the bucket, prefix, profile and region are fixed below and cannot |
| +# be overridden. The account is checked before anything else. Every key is |
| +# checked to start with exactly apps/bab// before it is acted on. There is no |
| +# --recursive and no prefix delete: each call names one key (and, with |
| +# --versions, one version id). The first failed call stops the run. |
| +# |
| +# Run it after the fixed agent has rolled to every box that uses this bucket, |
| +# or leo-climbing will write new ones (the old agent was still writing at |
| +# 2026-10-16 06:46Z). The listing and a log of every call go to a run |
| +# directory printed at the start. |
| +set -euo pipefail |
| + |
| +readonly BUCKET="carlos-dev-640012953448" |
| +readonly ACCOUNT="640012953448" |
| +readonly PREFIX="apps/bab//" |
| +readonly PROFILE="carloku-staging" |
| +readonly REGION="eu-west-1" |
| + |
| +apply=0 |
| +versions=0 |
| +for arg in "$@"; do |
| + case "$arg" in |
| + --apply) apply=1 ;; |
| + --versions) versions=1 ;; |
| + *) |
| + echo "usage: $0 [--apply [--versions]]" >&2 |
| + exit 2 |
| + ;; |
| + esac |
| +done |
| +if [ "$versions" = 1 ] && [ "$apply" = 0 ]; then |
| + echo "--versions only means something with --apply; a dry run already lists every version" >&2 |
| + exit 2 |
| +fi |
| + |
| +aws_() { aws --profile "$PROFILE" --region "$REGION" "$@"; } |
| + |
| +got_account="$(aws_ sts get-caller-identity --query Account --output text)" |
| +if [ "$got_account" != "$ACCOUNT" ]; then |
| + echo "profile $PROFILE is account $got_account, want $ACCOUNT; stopping" >&2 |
| + exit 1 |
| +fi |
| + |
| +run="$(mktemp -d "${TMPDIR:-/tmp}/empty-app-cleanup.XXXXXX")" |
| +echo "run directory: $run" |
| + |
| +# count prints "current versions markers" for the prefix. |
| +count() { |
| + local cur ver |
| + cur="$(aws_ s3api list-objects-v2 --bucket "$BUCKET" --prefix "$PREFIX" --output json | |
| + jq '[.Contents[]?] | length')" |
| + ver="$(aws_ s3api list-object-versions --bucket "$BUCKET" --prefix "$PREFIX" --output json | |
| + jq -r '"\([.Versions[]?] | length) \([.DeleteMarkers[]?] | length)"')" |
| + echo "$cur $ver" |
| +} |
| + |
| +# list writes the work list: one line per call, tab-separated |
| +# kind (object|version|marker), key, version id ("" for object). |
| +list() { |
| + aws_ s3api list-objects-v2 --bucket "$BUCKET" --prefix "$PREFIX" --output json | |
| + jq -r '.Contents[]? | ["object", .Key, ""] | @tsv' >"$run/objects.tsv" |
| + aws_ s3api list-object-versions --bucket "$BUCKET" --prefix "$PREFIX" --output json | |
| + jq -r '(.Versions[]? | ["version", .Key, .VersionId]), (.DeleteMarkers[]? | ["marker", .Key, .VersionId]) | @tsv' >"$run/versions.tsv" |
| +} |
| + |
| +# check refuses the whole run if any listed key is outside the prefix, or |
| +# carries a tab, newline or backslash (jq's @tsv escapes those with a |
| +# backslash, so the key read back would not be the key listed). |
| +check() { |
| + local kind key vid |
| + while IFS=$'\t' read -r kind key vid; do |
| + if [[ "$key" == *\\* ]]; then |
| + echo "listed key with an escaped character: $key; stopping, nothing deleted" >&2 |
| + exit 1 |
| + fi |
| + if [[ "$key" != "$PREFIX"* ]]; then |
| + echo "listed key outside $PREFIX: $key; stopping, nothing deleted" >&2 |
| + exit 1 |
| + fi |
| + case "$kind" in object | version | marker) ;; *) |
| + echo "unexpected row kind $kind for $key; stopping, nothing deleted" >&2 |
| + exit 1 |
| + ;; |
| + esac |
| + done <"$1" |
| +} |
| + |
| +read -r before_cur before_ver before_mark < <(count) |
| +echo "before: $before_cur current objects, $before_ver versions, $before_mark delete markers under $PREFIX" | tee "$run/counts.txt" |
| + |
| +list |
| +check "$run/objects.tsv" |
| +check "$run/versions.tsv" |
| +echo "listed: $(wc -l <"$run/objects.tsv") current objects ($run/objects.tsv), $(wc -l <"$run/versions.tsv") versions and markers ($run/versions.tsv)" |
| + |
| +if [ "$apply" = 0 ]; then |
| + echo "dry run: nothing deleted. First rows of each list:" |
| + head -5 "$run/objects.tsv" |
| + head -5 "$run/versions.tsv" |
| + echo "re-run with --apply (and --versions to remove every version too)" |
| + exit 0 |
| +fi |
| + |
| +work="$run/objects.tsv" |
| +if [ "$versions" = 1 ]; then |
| + work="$run/versions.tsv" |
| +fi |
| +n=0 |
| +while IFS=$'\t' read -r kind key vid; do |
| + if [ "$kind" = object ]; then |
| + aws_ s3api delete-object --bucket "$BUCKET" --key "$key" --output json >>"$run/calls.log" |
| + else |
| + aws_ s3api delete-object --bucket "$BUCKET" --key "$key" --version-id "$vid" --output json >>"$run/calls.log" |
| + fi |
| + printf '%s\t%s\t%s\n' "$kind" "$key" "$vid" >>"$run/deleted.tsv" |
| + n=$((n + 1)) |
| +done <"$work" |
| +echo "deleted: $n calls ($run/deleted.tsv)" |
| + |
| +read -r after_cur after_ver after_mark < <(count) |
| +echo "after: $after_cur current objects, $after_ver versions, $after_mark delete markers under $PREFIX" | tee -a "$run/counts.txt" |
diff --git a/internal/analytics/countries_test.go b/internal/analytics/countries_test.go| index 5995fedbd..38ec5edec 100644 |
| --- a/internal/analytics/countries_test.go |
| +++ b/internal/analytics/countries_test.go |
| @@ -41,7 +41,7 @@ func TestReadRangeMergesCountriesAcrossBoxesDaysAndOldDays(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| put := func(box, date string, d Day) { |
| raw, _ := json.Marshal(d) |
| - if _, err := s.Put(ctx, DayKey("a", "x", box, date), raw, ""); err != nil { |
| + if _, err := s.Put(ctx, must(t)(DayKey("bab", "x", box, date)), raw, ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -49,7 +49,7 @@ func TestReadRangeMergesCountriesAcrossBoxesDaysAndOldDays(t *testing.T) { |
| put("b1", "2026-10-04", Day{V: 1, Date: "2026-10-04", Countries: map[string]int64{}}) |
| put("b1", "2026-10-05", Day{V: 1, Date: "2026-10-05", Countries: map[string]int64{"GB": 3}, GeoSources: map[string]int64{"DBIP-City-Lite": 3}}) |
| put("b2", "2026-10-05", Day{V: 1, Date: "2026-10-05", Countries: map[string]int64{"GB": 1, "IE": 2}, GeoSources: map[string]int64{"GeoLite2-City": 3}}) |
| - sum, err := ReadRange(ctx, s, "a", "x", time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC), time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)) |
| + sum, err := ReadRange(ctx, s, "bab", "x", time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC), time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/analytics/day.go b/internal/analytics/day.go| index 34728aea8..9bbcd9c17 100644 |
| --- a/internal/analytics/day.go |
| +++ b/internal/analytics/day.go |
| @@ -77,14 +77,32 @@ type Delta struct { |
| requeued bool |
| } |
| -// Prefix is the app's analytics/ subtree. |
| -func Prefix(sqid, app string) string { return applog.FamilyPrefix(applog.FamilyAnalytics, sqid, app) } |
| +// Prefix is the app's analytics/ subtree. An invalid account or app is |
| +// refused (applog.ErrBadKeySegment), never built. |
| +func Prefix(sqid, app string) (string, error) { |
| + return applog.FamilyPrefix(applog.FamilyAnalytics, sqid, app) |
| +} |
| // BoxPrefix is one box's day files. |
| -func BoxPrefix(sqid, app, box string) string { return Prefix(sqid, app) + box + "/days/" } |
| +func BoxPrefix(sqid, app, box string) (string, error) { |
| + p, err := applog.FamilyBoxPrefix(applog.FamilyAnalytics, sqid, app, box) |
| + if err != nil { |
| + return "", err |
| + } |
| + return p + "days/", nil |
| +} |
| // DayKey is one day file. |
| -func DayKey(sqid, app, box, date string) string { return BoxPrefix(sqid, app, box) + date + ".json" } |
| +func DayKey(sqid, app, box, date string) (string, error) { |
| + p, err := BoxPrefix(sqid, app, box) |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := applog.CheckSegment("date", date); err != nil { |
| + return "", err |
| + } |
| + return p + date + ".json", nil |
| +} |
| // Merge folds x into d unless x.Seq is not above the recorded seq for |
| // x.Box — the idempotency rule that makes a retried conditional put safe. |
| @@ -146,7 +164,10 @@ func addDrops(a, b DropCounts) DropCounts { |
| // Merge, conditional put; on ErrCASConflict re-read and retry, three |
| // attempts. A no-op merge (seq already recorded) writes nothing. |
| func UpsertDay(ctx context.Context, s release.Store, sqid, app string, x Delta) error { |
| - key := DayKey(sqid, app, x.Box, x.Date) |
| + key, err := DayKey(sqid, app, x.Box, x.Date) |
| + if err != nil { |
| + return err |
| + } |
| for attempt := 0; attempt < 3; attempt++ { |
| var day Day |
| raw, etag, err := s.Get(ctx, key) |
diff --git a/internal/analytics/day_test.go b/internal/analytics/day_test.go| index f702d4000..25ac86a79 100644 |
| --- a/internal/analytics/day_test.go |
| +++ b/internal/analytics/day_test.go |
| @@ -77,7 +77,7 @@ func TestUpsertDayCASAcrossBoxes(t *testing.T) { |
| if err := UpsertDay(ctx, s, "bab", "web", delta("box-a", 2, 4)); err != nil { // retry of an ambiguous put |
| t.Fatal(err) |
| } |
| - raw, _, err := s.Get(ctx, DayKey("bab", "web", "box-a", "2026-09-07")) |
| + raw, _, err := s.Get(ctx, must(t)(DayKey("bab", "web", "box-a", "2026-09-07"))) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -86,9 +86,21 @@ func TestUpsertDayCASAcrossBoxes(t *testing.T) { |
| if d.Hours["09"].PV != 7 || d.Seq["box-a"] != 2 { |
| t.Fatalf("day = %+v", d) |
| } |
| - if DayKey("bab", "web", "box-a", "2026-09-07") != "apps/bab/web/analytics/box-a/days/2026-09-07.json" { |
| - t.Fatal(DayKey("bab", "web", "box-a", "2026-09-07")) |
| + if k := must(t)(DayKey("bab", "web", "box-a", "2026-09-07")); k != "apps/bab/web/analytics/box-a/days/2026-09-07.json" { |
| + t.Fatal(k) |
| } |
| } |
| func contains(s, sub string) bool { return strings.Contains(s, sub) } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: must(t)(ChunkKey(...)). |
| +func must(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/analytics/keyrefusal_test.go b/internal/analytics/keyrefusal_test.go| new file mode 100644 |
| index 000000000..1b697ceed |
| --- /dev/null |
| +++ b/internal/analytics/keyrefusal_test.go |
| @@ -0,0 +1,49 @@ |
| +package analytics |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestAnalyticsKeysRefuseEmptyApp: the day-file builders refuse an empty |
| +// app, account, box or date, and UpsertDay refuses before touching the |
| +// store, so nothing lands under apps/<sqid>//analytics/. |
| +func TestAnalyticsKeysRefuseEmptyApp(t *testing.T) { |
| + for _, c := range []struct{ acct, app, box, date string }{ |
| + {"bab", "", "box-a", "2026-10-16"}, |
| + {"", "web", "box-a", "2026-10-16"}, |
| + {"bab", "web", "", "2026-10-16"}, |
| + {"bab", "web", "box-a", ""}, |
| + {"bab", "web", "box-a", "2026/10/16"}, |
| + } { |
| + if k, err := DayKey(c.acct, c.app, c.box, c.date); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Errorf("DayKey%+v = (%q, %v)", c, k, err) |
| + } |
| + } |
| + if k, err := Prefix("bab", ""); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("Prefix = (%q, %v)", k, err) |
| + } |
| + if k, err := BoxPrefix("bab", "", "box-a"); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("BoxPrefix = (%q, %v)", k, err) |
| + } |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + err := UpsertDay(ctx, s, "bab", "", Delta{Box: "box-a", Date: "2026-10-16", Seq: 1}) |
| + if !errors.Is(err, applog.ErrBadKeySegment) { |
| + t.Fatalf("UpsertDay = %v", err) |
| + } |
| + if keys, _ := s.List(ctx, "apps/"); len(keys) != 0 { |
| + t.Fatalf("UpsertDay wrote %v", keys) |
| + } |
| + if _, err := ReadRange(ctx, s, "bab", "", time.Now(), time.Now()); !errors.Is(err, applog.ErrBadKeySegment) { |
| + t.Fatalf("ReadRange = %v", err) |
| + } |
| + if got := must(t)(BoxPrefix("bab", "web", "box-a")); got != "apps/bab/web/analytics/box-a/days/" { |
| + t.Fatalf("valid BoxPrefix moved: %q", got) |
| + } |
| +} |
diff --git a/internal/analytics/read.go b/internal/analytics/read.go| index a7fa43814..af9cd99d9 100644 |
| --- a/internal/analytics/read.go |
| +++ b/internal/analytics/read.go |
| @@ -62,7 +62,11 @@ func Dates(from, to time.Time) []string { |
| // day; a store error fails the read. |
| func ReadRange(ctx context.Context, s release.Store, sqid, app string, from, to time.Time) (Summary, error) { |
| sum := Summary{From: usage.DayOf(from), To: usage.DayOf(to)} |
| - boxDirs, err := s.ListDirs(ctx, Prefix(sqid, app)) |
| + prefix, err := Prefix(sqid, app) |
| + if err != nil { |
| + return sum, err |
| + } |
| + boxDirs, err := s.ListDirs(ctx, prefix) |
| if err != nil { |
| return sum, err |
| } |
| @@ -76,7 +80,11 @@ func ReadRange(ctx context.Context, s release.Store, sqid, app string, from, to |
| ds := DaySummary{Date: date} |
| vis := &HLL{} |
| for _, box := range sum.Boxes { |
| - raw, _, err := s.Get(ctx, DayKey(sqid, app, box, date)) |
| + key, err := DayKey(sqid, app, box, date) |
| + if err != nil { |
| + return sum, err |
| + } |
| + raw, _, err := s.Get(ctx, key) |
| if errors.Is(err, os.ErrNotExist) { |
| continue |
| } |
diff --git a/internal/apperr/keyrefusal_test.go b/internal/apperr/keyrefusal_test.go| new file mode 100644 |
| index 000000000..2eebb786b |
| --- /dev/null |
| +++ b/internal/apperr/keyrefusal_test.go |
| @@ -0,0 +1,31 @@ |
| +package apperr |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestErrorsKeysRefuseEmptyApp: the errors family's own builders refuse |
| +// the empty app an app-less route is filed under, and a read for it fails |
| +// rather than listing apps/<sqid>//errors/. |
| +func TestErrorsKeysRefuseEmptyApp(t *testing.T) { |
| + if k, err := Prefix("bab", ""); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("Prefix = (%q, %v)", k, err) |
| + } |
| + if k, err := BoxPrefix("bab", "", "leo-climbing"); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("BoxPrefix = (%q, %v)", k, err) |
| + } |
| + if k, err := ChunkKey("bab", "", "leo-climbing", "2026-10-16-06", 1, 2); !errors.Is(err, applog.ErrBadKeySegment) || k != "" { |
| + t.Fatalf("ChunkKey = (%q, %v)", k, err) |
| + } |
| + if _, _, err := ReadWindow(context.Background(), release.NewDirStore(t.TempDir()), "bab", "", Filter{}, 10); !errors.Is(err, applog.ErrBadKeySegment) { |
| + t.Fatalf("ReadWindow = %v", err) |
| + } |
| + if got := must(t)(ChunkKey("bab", "web", "leo-climbing", "2026-10-16-06", 1792015319229, 257)); got != "apps/bab/web/errors/leo-climbing/2026-10-16-06/0001792015319229-257.jsonl.gz" { |
| + t.Fatalf("valid ChunkKey moved: %q", got) |
| + } |
| +} |
diff --git a/internal/apperr/read.go b/internal/apperr/read.go| index d17608b7e..96aac7cfc 100644 |
| --- a/internal/apperr/read.go |
| +++ b/internal/apperr/read.go |
| @@ -11,15 +11,18 @@ import ( |
| // Prefix is the app's errors/ subtree (spec §3.2): a sibling family of |
| // logs/ with the same key shape below it. |
| -func Prefix(acct, app string) string { return applog.FamilyPrefix(applog.FamilyErrors, acct, app) } |
| +// An invalid account or app is refused (applog.ErrBadKeySegment). |
| +func Prefix(acct, app string) (string, error) { |
| + return applog.FamilyPrefix(applog.FamilyErrors, acct, app) |
| +} |
| // BoxPrefix is one box's subtree within an app's errors. |
| -func BoxPrefix(acct, app, box string) string { |
| +func BoxPrefix(acct, app, box string) (string, error) { |
| return applog.FamilyBoxPrefix(applog.FamilyErrors, acct, app, box) |
| } |
| // ChunkKey names one flushed errors chunk; see applog.ChunkKey. |
| -func ChunkKey(acct, app, box, hour string, seq uint64, size int) string { |
| +func ChunkKey(acct, app, box, hour string, seq uint64, size int) (string, error) { |
| return applog.FamilyChunkKey(applog.FamilyErrors, acct, app, box, hour, seq, size) |
| } |
| @@ -89,7 +92,11 @@ func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter |
| if !listSince.IsZero() { |
| listSince = listSince.Add(-time.Hour) |
| } |
| - perBox, trimmed, err := applog.ReadChunks[Record](ctx, s, Prefix(acct, app), listSince, f.Until, f.Match, collectCap) |
| + prefix, err := Prefix(acct, app) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + perBox, trimmed, err := applog.ReadChunks[Record](ctx, s, prefix, listSince, f.Until, f.Match, collectCap) |
| if err != nil { |
| return nil, false, err |
| } |
diff --git a/internal/apperr/read_test.go b/internal/apperr/read_test.go| index deed9a676..b0deaa25b 100644 |
| --- a/internal/apperr/read_test.go |
| +++ b/internal/apperr/read_test.go |
| @@ -17,7 +17,7 @@ func TestReadWindowMergesAndFilters(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := s.Put(ctx, ChunkKey("bab", "web", box, HourOf(recs[0].TS), recs[0].Seq, len(body)), body, ""); err != nil { |
| + if _, err := s.Put(ctx, must(t)(ChunkKey("bab", "web", box, HourOf(recs[0].TS), recs[0].Seq, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -72,7 +72,7 @@ func TestReadWindowListsHourBeforeSince(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := s.Put(ctx, ChunkKey("bab", "web", "box1", HourOf(recs[0].TS), recs[0].Seq, len(body)), body, ""); err != nil { |
| + if _, err := s.Put(ctx, must(t)(ChunkKey("bab", "web", "box1", HourOf(recs[0].TS), recs[0].Seq, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| since := time.Date(2026, 9, 7, 11, 0, 0, 0, time.UTC) |
| @@ -85,3 +85,15 @@ func TestReadWindowListsHourBeforeSince(t *testing.T) { |
| t.Fatalf("window = %+v, want only flong", got) |
| } |
| } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: must(t)(ChunkKey(...)). |
| +func must(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/applog/chunk.go b/internal/applog/chunk.go| index ef3a8b56b..b7935d7fa 100644 |
| --- a/internal/applog/chunk.go |
| +++ b/internal/applog/chunk.go |
| @@ -5,6 +5,7 @@ import ( |
| "bytes" |
| "compress/gzip" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "io" |
| "strconv" |
| @@ -46,38 +47,88 @@ const ( |
| FamilyAnalytics Family = "analytics" |
| ) |
| +// ErrBadKeySegment is every family builder's refusal: an account or app |
| +// that is not a valid name, or a box or hour that is empty or carries a |
| +// slash. Writers skip the write on it; readers fail the read. A bad |
| +// segment never becomes a key, so no box can write apps/<sqid>//... (an |
| +// app-less route once did exactly that, and hostops.Apps, rightly, refuses |
| +// to walk a bucket holding it). |
| +var ErrBadKeySegment = errors.New("applog: refusing to build a key from an empty or invalid segment") |
| + |
| +// CheckApp is the account and app half of every family builder's check: |
| +// the same names hostops.Apps accepts when it walks apps/. |
| +func CheckApp(acct, app string) error { |
| + if !release.ValidAccount(acct) || !release.ValidApp(app) { |
| + return fmt.Errorf("%w: account %q, app %q", ErrBadKeySegment, acct, app) |
| + } |
| + return nil |
| +} |
| + |
| +// CheckSegment refuses a box, hour or date segment that would collapse or |
| +// split a path level. internal/analytics uses it for its day-file leaf. |
| +func CheckSegment(what, v string) error { |
| + if v == "" || strings.ContainsRune(v, '/') { |
| + return fmt.Errorf("%w: %s %q", ErrBadKeySegment, what, v) |
| + } |
| + return nil |
| +} |
| + |
| // FamilyPrefix is the app's whole subtree for one family — under |
| // release.AppPrefix on purpose (spec §3): it is the app's data, so the |
| // owner gate maps onto the prefix and app trash/purge sweeps it for free. |
| -func FamilyPrefix(fam Family, acct, app string) string { |
| - return release.AppPrefix(acct, app) + string(fam) + "/" |
| +// An invalid account or app is refused (ErrBadKeySegment), never built. |
| +func FamilyPrefix(fam Family, acct, app string) (string, error) { |
| + if err := CheckApp(acct, app); err != nil { |
| + return "", err |
| + } |
| + return release.AppPrefix(acct, app) + string(fam) + "/", nil |
| } |
| // FamilyBoxPrefix is one box's subtree within a family — the unit of |
| // write-side ownership: each box prunes only what it wrote. |
| -func FamilyBoxPrefix(fam Family, acct, app, box string) string { |
| - return FamilyPrefix(fam, acct, app) + box + "/" |
| +func FamilyBoxPrefix(fam Family, acct, app, box string) (string, error) { |
| + p, err := FamilyPrefix(fam, acct, app) |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := CheckSegment("box", box); err != nil { |
| + return "", err |
| + } |
| + return p + box + "/", nil |
| } |
| // FamilyHourPrefix is one UTC hour within a box's subtree. |
| -func FamilyHourPrefix(fam Family, acct, app, box, hour string) string { |
| - return FamilyBoxPrefix(fam, acct, app, box) + hour + "/" |
| +func FamilyHourPrefix(fam Family, acct, app, box, hour string) (string, error) { |
| + p, err := FamilyBoxPrefix(fam, acct, app, box) |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := CheckSegment("hour", hour); err != nil { |
| + return "", err |
| + } |
| + return p + hour + "/", nil |
| } |
| // FamilyChunkKey names one flushed chunk in any family. See ChunkKey for |
| // why seq and size live in the key. |
| -func FamilyChunkKey(fam Family, acct, app, box, hour string, seq uint64, size int) string { |
| - return fmt.Sprintf("%s%016d-%d.jsonl.gz", FamilyHourPrefix(fam, acct, app, box, hour), seq, size) |
| +func FamilyChunkKey(fam Family, acct, app, box, hour string, seq uint64, size int) (string, error) { |
| + p, err := FamilyHourPrefix(fam, acct, app, box, hour) |
| + if err != nil { |
| + return "", err |
| + } |
| + return fmt.Sprintf("%s%016d-%d.jsonl.gz", p, seq, size), nil |
| } |
| // Prefix is the app's whole log subtree: FamilyPrefix for logs. |
| -func Prefix(acct, app string) string { return FamilyPrefix(FamilyLogs, acct, app) } |
| +func Prefix(acct, app string) (string, error) { return FamilyPrefix(FamilyLogs, acct, app) } |
| // BoxPrefix is one box's subtree within an app's logs. |
| -func BoxPrefix(acct, app, box string) string { return FamilyBoxPrefix(FamilyLogs, acct, app, box) } |
| +func BoxPrefix(acct, app, box string) (string, error) { |
| + return FamilyBoxPrefix(FamilyLogs, acct, app, box) |
| +} |
| // HourPrefix is one UTC hour within a box's log subtree. |
| -func HourPrefix(acct, app, box, hour string) string { |
| +func HourPrefix(acct, app, box, hour string) (string, error) { |
| return FamilyHourPrefix(FamilyLogs, acct, app, box, hour) |
| } |
| @@ -89,7 +140,7 @@ func HourPrefix(acct, app, box, hour string) string { |
| // sizes — embedding them costs a suffix, extending the Store interface |
| // costs every implementation. Chunks are written once and never appended |
| // or rewritten (spec §3). |
| -func ChunkKey(acct, app, box, hour string, seq uint64, size int) string { |
| +func ChunkKey(acct, app, box, hour string, seq uint64, size int) (string, error) { |
| return FamilyChunkKey(FamilyLogs, acct, app, box, hour, seq, size) |
| } |
diff --git a/internal/applog/chunk_test.go b/internal/applog/chunk_test.go| index 9ab61b891..fb9d00c32 100644 |
| --- a/internal/applog/chunk_test.go |
| +++ b/internal/applog/chunk_test.go |
| @@ -39,18 +39,18 @@ func TestKeysAndHourFormat(t *testing.T) { |
| if got := HourOf(at); got != "2026-08-10-21" { |
| t.Fatalf("HourOf = %q", got) // same vocabulary as metrics' hourFormat |
| } |
| - if got := Prefix("bde", "ledgerpay"); got != "apps/bde/ledgerpay/logs/" { |
| + if got := must(t)(Prefix("bde", "ledgerpay")); got != "apps/bde/ledgerpay/logs/" { |
| t.Fatalf("Prefix = %q", got) |
| } |
| - if got := BoxPrefix("bde", "ledgerpay", "box1"); got != "apps/bde/ledgerpay/logs/box1/" { |
| + if got := must(t)(BoxPrefix("bde", "ledgerpay", "box1")); got != "apps/bde/ledgerpay/logs/box1/" { |
| t.Fatalf("BoxPrefix = %q", got) |
| } |
| - if got := HourPrefix("bde", "ledgerpay", "box1", "2026-08-10-21"); got != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/" { |
| + if got := must(t)(HourPrefix("bde", "ledgerpay", "box1", "2026-08-10-21")); got != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/" { |
| t.Fatalf("HourPrefix = %q", got) |
| } |
| // Zero-padded so lexical order is numeric order; the byte size rides in |
| // the key so retention can count without a Store interface change. |
| - key := ChunkKey("bde", "ledgerpay", "box1", "2026-08-10-21", 41822, 8123) |
| + key := must(t)(ChunkKey("bde", "ledgerpay", "box1", "2026-08-10-21", 41822, 8123)) |
| if key != "apps/bde/ledgerpay/logs/box1/2026-08-10-21/0000000000041822-8123.jsonl.gz" { |
| t.Fatalf("ChunkKey = %q", key) |
| } |
| @@ -133,13 +133,13 @@ func TestHourBefore(t *testing.T) { |
| } |
| func TestFamilyKeysMirrorLogs(t *testing.T) { |
| - if got, want := FamilyPrefix(FamilyLogs, "bab", "web"), Prefix("bab", "web"); got != want { |
| + if got, want := must(t)(FamilyPrefix(FamilyLogs, "bab", "web")), must(t)(Prefix("bab", "web")); got != want { |
| t.Fatalf("FamilyPrefix(logs) = %q, Prefix = %q", got, want) |
| } |
| - if got, want := FamilyChunkKey(FamilyLogs, "bab", "web", "box1", "2026-09-07-10", 42, 7), ChunkKey("bab", "web", "box1", "2026-09-07-10", 42, 7); got != want { |
| + if got, want := must(t)(FamilyChunkKey(FamilyLogs, "bab", "web", "box1", "2026-09-07-10", 42, 7)), must(t)(ChunkKey("bab", "web", "box1", "2026-09-07-10", 42, 7)); got != want { |
| t.Fatalf("FamilyChunkKey(logs) = %q, ChunkKey = %q", got, want) |
| } |
| - got := FamilyChunkKey(FamilyErrors, "bab", "web", "box1", "2026-09-07-10", 42, 7) |
| + got := must(t)(FamilyChunkKey(FamilyErrors, "bab", "web", "box1", "2026-09-07-10", 42, 7)) |
| want := "apps/bab/web/errors/box1/2026-09-07-10/0000000000000042-7.jsonl.gz" |
| if got != want { |
| t.Fatalf("errors key = %q, want %q", got, want) |
| @@ -176,3 +176,15 @@ func TestEncodeJSONLRoundTripsAnyShape(t *testing.T) { |
| t.Fatalf("EncodeChunk and EncodeJSONL disagree: %+v vs %+v", da, dc) |
| } |
| } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: must(t)(ChunkKey(...)). |
| +func must(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/applog/keyrefusal_test.go b/internal/applog/keyrefusal_test.go| new file mode 100644 |
| index 000000000..5ce3ba3ea |
| --- /dev/null |
| +++ b/internal/applog/keyrefusal_test.go |
| @@ -0,0 +1,84 @@ |
| +package applog |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestFamilyBuildersRefuseBadSegments: no builder in the apps/<sqid>/<app>/ |
| +// families can produce a key from an empty or invalid account or app, or an |
| +// empty or slash-bearing box or hour. The empty app is the regression: an |
| +// app-less route was filed under its account with app "", and the errors |
| +// shipper wrote apps/bab//errors/..., which hostops.Apps refuses to walk. |
| +func TestFamilyBuildersRefuseBadSegments(t *testing.T) { |
| + type build func(acct, app, box, hour string) (string, error) |
| + builders := map[string]build{} |
| + for _, fam := range []Family{FamilyLogs, FamilyErrors, FamilyAnalytics} { |
| + builders["FamilyPrefix/"+string(fam)] = func(acct, app, _, _ string) (string, error) { return FamilyPrefix(fam, acct, app) } |
| + builders["FamilyBoxPrefix/"+string(fam)] = func(acct, app, box, _ string) (string, error) { return FamilyBoxPrefix(fam, acct, app, box) } |
| + builders["FamilyHourPrefix/"+string(fam)] = func(acct, app, box, hour string) (string, error) { |
| + return FamilyHourPrefix(fam, acct, app, box, hour) |
| + } |
| + builders["FamilyChunkKey/"+string(fam)] = func(acct, app, box, hour string) (string, error) { |
| + return FamilyChunkKey(fam, acct, app, box, hour, 1, 2) |
| + } |
| + } |
| + builders["Prefix"] = func(acct, app, _, _ string) (string, error) { return Prefix(acct, app) } |
| + builders["BoxPrefix"] = func(acct, app, box, _ string) (string, error) { return BoxPrefix(acct, app, box) } |
| + builders["HourPrefix"] = func(acct, app, box, hour string) (string, error) { return HourPrefix(acct, app, box, hour) } |
| + builders["ChunkKey"] = func(acct, app, box, hour string) (string, error) { return ChunkKey(acct, app, box, hour, 1, 2) } |
| + |
| + // Each case breaks exactly one segment. A box or hour case only applies |
| + // to builders that take that segment: a prefix above the box level |
| + // never sees the box. |
| + cases := []struct { |
| + name string |
| + acct, app, box, hr string |
| + needsBox, needsHour bool |
| + }{ |
| + {"empty app", "bab", "", "box1", "2026-10-16-06", false, false}, |
| + {"empty account", "", "web", "box1", "2026-10-16-06", false, false}, |
| + {"invalid app", "bab", "Web/x", "box1", "2026-10-16-06", false, false}, |
| + {"invalid account", "b", "web", "box1", "2026-10-16-06", false, false}, |
| + {"empty box", "bab", "web", "", "2026-10-16-06", true, false}, |
| + {"slash in box", "bab", "web", "a/b", "2026-10-16-06", true, false}, |
| + {"empty hour", "bab", "web", "box1", "", false, true}, |
| + {"slash in hour", "bab", "web", "box1", "2026/10", false, true}, |
| + } |
| + for name, b := range builders { |
| + usesBox := !strings.HasPrefix(name, "FamilyPrefix") && name != "Prefix" |
| + usesHour := strings.Contains(name, "Hour") || strings.Contains(name, "ChunkKey") |
| + for _, c := range cases { |
| + if (c.needsBox && !usesBox) || (c.needsHour && !usesHour) { |
| + continue |
| + } |
| + key, err := b(c.acct, c.app, c.box, c.hr) |
| + if !errors.Is(err, ErrBadKeySegment) || key != "" { |
| + t.Errorf("%s with %s = (%q, %v), want (\"\", ErrBadKeySegment)", name, c.name, key, err) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestFamilyKeysByteIdentical pins every builder's output for valid names: |
| +// the refusal must not move a single byte of a key that was already good. |
| +func TestFamilyKeysByteIdentical(t *testing.T) { |
| + m := must(t) |
| + for _, c := range []struct{ got, want string }{ |
| + {m(FamilyPrefix(FamilyErrors, "bab", "web")), "apps/bab/web/errors/"}, |
| + {m(FamilyBoxPrefix(FamilyErrors, "bab", "web", "leo-climbing")), "apps/bab/web/errors/leo-climbing/"}, |
| + {m(FamilyHourPrefix(FamilyErrors, "bab", "web", "leo-climbing", "2026-10-16-06")), "apps/bab/web/errors/leo-climbing/2026-10-16-06/"}, |
| + {m(FamilyChunkKey(FamilyErrors, "bab", "web", "leo-climbing", "2026-10-16-06", 1792015319229, 257)), "apps/bab/web/errors/leo-climbing/2026-10-16-06/0001792015319229-257.jsonl.gz"}, |
| + {m(FamilyPrefix(FamilyAnalytics, "bde", "ledgerpay")), "apps/bde/ledgerpay/analytics/"}, |
| + {m(FamilyBoxPrefix(FamilyAnalytics, "bde", "ledgerpay", "box-a")), "apps/bde/ledgerpay/analytics/box-a/"}, |
| + {m(Prefix("bde", "ledgerpay")), "apps/bde/ledgerpay/logs/"}, |
| + {m(BoxPrefix("bde", "ledgerpay", "box1")), "apps/bde/ledgerpay/logs/box1/"}, |
| + {m(HourPrefix("bde", "ledgerpay", "box1", "2026-08-10-21")), "apps/bde/ledgerpay/logs/box1/2026-08-10-21/"}, |
| + {m(ChunkKey("bde", "ledgerpay", "box1", "2026-08-10-21", 41822, 8123)), "apps/bde/ledgerpay/logs/box1/2026-08-10-21/0000000000041822-8123.jsonl.gz"}, |
| + } { |
| + if c.got != c.want { |
| + t.Errorf("key = %q, want %q", c.got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/applog/read.go b/internal/applog/read.go| index 355dea8a0..207a4946c 100644 |
| --- a/internal/applog/read.go |
| +++ b/internal/applog/read.go |
| @@ -196,7 +196,11 @@ func forEachBounded(ctx context.Context, n int, fn func(i int) error) error { |
| // second return says so. Store errors fail the read — a partial answer |
| // that looks whole is the one thing this must never produce. |
| func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error) { |
| - perBox, trimmed, err := ReadChunks[Record](ctx, s, Prefix(acct, app), f.Since, f.Until, f.Match, collectCap) |
| + prefix, err := Prefix(acct, app) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + perBox, trimmed, err := ReadChunks[Record](ctx, s, prefix, f.Since, f.Until, f.Match, collectCap) |
| if err != nil { |
| return nil, false, err |
| } |
diff --git a/internal/applog/read_test.go b/internal/applog/read_test.go| index 90ef3f1cc..316484154 100644 |
| --- a/internal/applog/read_test.go |
| +++ b/internal/applog/read_test.go |
| @@ -23,7 +23,7 @@ func seedWindow(t *testing.T, s release.Store) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := s.Put(ctx, ChunkKey("bde", "ledgerpay", box, hour, seq, len(body)), body, ""); err != nil { |
| + if _, err := s.Put(ctx, must(t)(ChunkKey("bde", "ledgerpay", box, hour, seq, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -119,11 +119,11 @@ func TestReadChunksWalksAnyFamily(t *testing.T) { |
| } |
| now := time.Date(2026, 9, 7, 10, 30, 0, 0, time.UTC) |
| body, _ := EncodeJSONL([]ev{{TS: now, Name: "boom"}, {TS: now.Add(time.Minute), Name: "skip"}}) |
| - key := FamilyChunkKey(FamilyErrors, "bab", "web", "box1", HourOf(now), 1, len(body)) |
| + key := must(t)(FamilyChunkKey(FamilyErrors, "bab", "web", "box1", HourOf(now), 1, len(body))) |
| if _, err := s.Put(ctx, key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| - perBox, trimmed, err := ReadChunks[ev](ctx, s, FamilyPrefix(FamilyErrors, "bab", "web"), |
| + perBox, trimmed, err := ReadChunks[ev](ctx, s, must(t)(FamilyPrefix(FamilyErrors, "bab", "web")), |
| now.Add(-time.Hour), now.Add(time.Hour), func(e ev) bool { return e.Name != "skip" }, 100) |
| if err != nil || trimmed { |
| t.Fatalf("ReadChunks: %v trimmed=%v", err, trimmed) |
| @@ -132,7 +132,7 @@ func TestReadChunksWalksAnyFamily(t *testing.T) { |
| t.Fatalf("perBox = %+v", perBox) |
| } |
| // An hour outside the window is never listed. |
| - perBox, _, err = ReadChunks[ev](ctx, s, FamilyPrefix(FamilyErrors, "bab", "web"), |
| + perBox, _, err = ReadChunks[ev](ctx, s, must(t)(FamilyPrefix(FamilyErrors, "bab", "web")), |
| now.Add(2*time.Hour), time.Time{}, func(ev) bool { return true }, 100) |
| if err != nil || len(perBox) != 1 || len(perBox[0]) != 0 { |
| t.Fatalf("out-of-window read = %+v, %v", perBox, err) |
| @@ -185,7 +185,7 @@ func TestReadChunksConcurrentIsSerialOrder(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := ChunkKey("bde", "ledgerpay", box, HourOf(hour), seq, len(body)) |
| + key := must(t)(ChunkKey("bde", "ledgerpay", box, HourOf(hour), seq, len(body))) |
| if _, err := dir.Put(ctx, key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -196,7 +196,7 @@ func TestReadChunksConcurrentIsSerialOrder(t *testing.T) { |
| } |
| s := &slowStore{Store: dir} |
| all := func(Record) bool { return true } |
| - perBox, trimmed, err := ReadChunks[Record](ctx, s, Prefix("bde", "ledgerpay"), time.Time{}, time.Time{}, all, 1000) |
| + perBox, trimmed, err := ReadChunks[Record](ctx, s, must(t)(Prefix("bde", "ledgerpay")), time.Time{}, time.Time{}, all, 1000) |
| if err != nil || trimmed || len(perBox) != 3 { |
| t.Fatalf("read: %v trimmed=%v boxes=%d", err, trimmed, len(perBox)) |
| } |
| @@ -213,13 +213,13 @@ func TestReadChunksConcurrentIsSerialOrder(t *testing.T) { |
| t.Fatalf("peak in-flight GETs = %d, want 2..%d", s.peak, readConcurrency) |
| } |
| - perBox, trimmed, err = ReadChunks[Record](ctx, s, Prefix("bde", "ledgerpay"), time.Time{}, time.Time{}, all, 5) |
| + perBox, trimmed, err = ReadChunks[Record](ctx, s, must(t)(Prefix("bde", "ledgerpay")), time.Time{}, time.Time{}, all, 5) |
| if err != nil || !trimmed || len(perBox[0]) != 5 || perBox[0][4].Msg != "box1 h4 c7" || perBox[0][0].Msg != "box1 h4 c3" { |
| t.Fatalf("capped read kept %+v (err %v, trimmed %v)", perBox[0], err, trimmed) |
| } |
| s.fail = someKey |
| - if _, _, err := ReadChunks[Record](ctx, s, Prefix("bde", "ledgerpay"), time.Time{}, time.Time{}, all, 1000); err == nil { |
| + if _, _, err := ReadChunks[Record](ctx, s, must(t)(Prefix("bde", "ledgerpay")), time.Time{}, time.Time{}, all, 1000); err == nil { |
| t.Fatal("a failed chunk must fail the read, never return a partial answer") |
| } |
| } |
diff --git a/internal/console/apierrors_test.go b/internal/console/apierrors_test.go| index bc5dcab34..fe9996790 100644 |
| --- a/internal/console/apierrors_test.go |
| +++ b/internal/console/apierrors_test.go |
| @@ -38,7 +38,7 @@ func seedErrors(t *testing.T, fx *consoleFixture, now time.Time) { |
| {TS: now.Add(-6 * time.Minute), Box: "box1", Host: "titogo.example.com", |
| Stream: applog.StreamPlatform, Level: applog.LevelWarn, Msg: "dropped 12 app log line(s) (rate cap)", Seq: 152}} |
| body, _ := applog.EncodeChunk(warn) |
| - if _, err := fx.rel.Put(context.Background(), applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(warn[0].TS), 150, len(body)), body, ""); err != nil { |
| + if _, err := fx.rel.Put(context.Background(), mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(warn[0].TS), 150, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| e := []apperr.Record{{TS: now.Add(-3 * time.Minute), Box: "box1", Host: "titogo.example.com", |
| @@ -55,7 +55,7 @@ func seedErrors(t *testing.T, fx *consoleFixture, now time.Time) { |
| Name: apperr.AvailabilityName(502, apperr.CauseRefused), Path: "/admin", Status: 502, |
| Release: "v4", FP: "new502", FPV: 1, Count: 5, First: now.Add(-4 * time.Minute), Last: now.Add(-2 * time.Minute), Seq: 203}} |
| eb, _ := apperr.EncodeChunk(e) |
| - if _, err := fx.rel.Put(context.Background(), apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(e[0].TS), 200, len(eb)), eb, ""); err != nil { |
| + if _, err := fx.rel.Put(context.Background(), mustKey(t)(apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(e[0].TS), 200, len(eb))), eb, ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -196,7 +196,7 @@ func TestAppErrorsPageCaps(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := fx.rel.Put(context.Background(), apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(recs[0].TS), 1000, len(body)), body, ""); err != nil { |
| + if _, err := fx.rel.Put(context.Background(), mustKey(t)(apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(recs[0].TS), 1000, len(body))), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| fx.setUser("paul@keymail.dev") |
diff --git a/internal/console/apilogs_test.go b/internal/console/apilogs_test.go| index 4deb40d38..781213f8a 100644 |
| --- a/internal/console/apilogs_test.go |
| +++ b/internal/console/apilogs_test.go |
| @@ -40,7 +40,7 @@ func seedLogChunks(t *testing.T, fx *consoleFixture, now time.Time) []applog.Rec |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), recs[0].Seq, len(body))) |
| if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -58,7 +58,7 @@ func seedManyLogRecords(t *testing.T, fx *consoleFixture, now time.Time, n int) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := applog.ChunkKey(release.OpsAccount, "titogo", "box-many", applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box-many", applog.HourOf(recs[0].TS), recs[0].Seq, len(body))) |
| if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -133,7 +133,7 @@ func TestAPILogsTailFiltersAndFollow(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(extra[0].TS), 102, len(body)) |
| + key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(extra[0].TS), 102, len(body))) |
| if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -350,3 +350,15 @@ func TestAppLogsPage(t *testing.T) { |
| t.Fatalf("empty window page: %d", status) |
| } |
| } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: mustKey(t)(ChunkKey(...)). |
| +func mustKey(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/edge/fleetapi_e2e_test.go b/internal/edge/fleetapi_e2e_test.go| index f6ab8c353..d28c4ad69 100644 |
| --- a/internal/edge/fleetapi_e2e_test.go |
| +++ b/internal/edge/fleetapi_e2e_test.go |
| @@ -218,7 +218,10 @@ func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) { |
| w.put(t, "accounts/"+w.other+"/account.json", fapiAccountManifest(fapiOtherID, w.other, "Intruder")) |
| otherChannelKey := "apps/" + w.other + "/" + fapiOtherApp + "/channels/stable.json" |
| w.put(t, otherChannelKey, `{"app":"intruder","version":"v9"}`) |
| - otherLogKey := applog.ChunkKey(w.other, fapiOtherApp, fapiBoxA, applog.HourOf(time.Now()), 1, 9) |
| + otherLogKey, err := applog.ChunkKey(w.other, fapiOtherApp, fapiBoxA, applog.HourOf(time.Now()), 1, 9) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| w.put(t, otherLogKey, "not yours") |
| w.put(t, "fleets/other/status/boxes/box-a.json", `{"label":"box-a"}`) |
| @@ -507,7 +510,10 @@ func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) { |
| if err != nil { |
| t.Fatalf("(g) EncodeChunk: %v", err) |
| } |
| - chunkKey := applog.ChunkKey(w.sqid, fapiApp, fapiBoxA, hour, 1, len(chunk)) |
| + chunkKey, err := applog.ChunkKey(w.sqid, fapiApp, fapiBoxA, hour, 1, len(chunk)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if _, err := w.boxA.Put(ctx, chunkKey, chunk, ""); err != nil { |
| t.Fatalf("(g) ship a log chunk through the door: %v", err) |
| } |
| @@ -519,7 +525,11 @@ func TestFleetBoxConvergesWithoutBucketCredentials(t *testing.T) { |
| if err != nil || len(recs) != 1 || recs[0].Msg != "listening" { |
| t.Fatalf("(g) chunk round trip = %+v, %v", recs, err) |
| } |
| - listed, err := w.boxA.List(ctx, applog.BoxPrefix(w.sqid, fapiApp, fapiBoxA)) |
| + boxPrefix, err := applog.BoxPrefix(w.sqid, fapiApp, fapiBoxA) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + listed, err := w.boxA.List(ctx, boxPrefix) |
| if err != nil || !slices.Contains(listed, chunkKey) { |
| t.Fatalf("(g) list own log subtree = %v, %v; want %s", listed, err, chunkKey) |
| } |
diff --git a/internal/host/applessroute_test.go b/internal/host/applessroute_test.go| new file mode 100644 |
| index 000000000..8bed6c30b |
| --- /dev/null |
| +++ b/internal/host/applessroute_test.go |
| @@ -0,0 +1,209 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "log" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/analytics" |
| + "github.com/carlosframework/platform/internal/apperr" |
| + "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// keyLog records every key a shipper touches, so a test can see a key that |
| +// a directory-backed store would have quietly normalised ("a//b" is "a/b" |
| +// on disk). |
| +type keyLog struct { |
| + release.Store |
| + mu sync.Mutex |
| + touched []string |
| +} |
| + |
| +func (k *keyLog) note(key string) { |
| + k.mu.Lock() |
| + k.touched = append(k.touched, key) |
| + k.mu.Unlock() |
| +} |
| + |
| +func (k *keyLog) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + k.note("put " + key) |
| + return k.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (k *keyLog) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + k.note("get " + key) |
| + return k.Store.Get(ctx, key) |
| +} |
| + |
| +func (k *keyLog) List(ctx context.Context, prefix string) ([]string, error) { |
| + k.note("list " + prefix) |
| + return k.Store.List(ctx, prefix) |
| +} |
| + |
| +func (k *keyLog) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + k.note("listdirs " + prefix) |
| + return k.Store.ListDirs(ctx, prefix) |
| +} |
| + |
| +func (k *keyLog) Delete(ctx context.Context, key string) error { |
| + k.note("delete " + key) |
| + return k.Store.Delete(ctx, key) |
| +} |
| + |
| +func (k *keyLog) all() []string { |
| + k.mu.Lock() |
| + defer k.mu.Unlock() |
| + return append([]string(nil), k.touched...) |
| +} |
| + |
| +// captureShipLog swaps the standard logger's output for the test and forgets |
| +// which apps unshippable has reported, so its once-only line is observable. |
| +func captureShipLog(t *testing.T) *bytes.Buffer { |
| + t.Helper() |
| + unshippableSeen.Range(func(k, _ any) bool { unshippableSeen.Delete(k); return true }) |
| + var buf bytes.Buffer |
| + prev, flags := log.Writer(), log.Flags() |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) |
| + return &buf |
| +} |
| + |
| +// TestErrshipAppLessRouteWritesNothing is the dev-bucket regression end to |
| +// end. The route is the shape found on leo-climbing: an instance row with |
| +// an account, no app, and a TCP upstream nobody listens on. Every request |
| +// is a refused 502 that the edge files under (bab, ""). The errors shipper |
| +// must write nothing for it (it once wrote apps/bab//errors/...), prune |
| +// nothing, keep shipping the app beside it, and say so once. |
| +func TestErrshipAppLessRouteWritesNothing(t *testing.T) { |
| + dead := httptest.NewServer(http.NotFoundHandler()) |
| + deadAddr := dead.Listener.Addr().String() |
| + dead.Close() // nothing listens here now: every dial is refused |
| + |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range []registry.Route{ |
| + {Host: "proof.example.com", Kind: "instance", Account: "bab", App: "", Addr: deadAddr, Status: "ready", Channel: "stable"}, |
| + {Host: "web.example.com", Kind: "service", Account: "bab", App: "web", Addr: deadAddr, Status: "ready", Version: "v1"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + e, err := edge.New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sink := apperr.NewSink("leo-climbing", time.Now) |
| + e.EnableErrors(sink) |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + get := func(host string) { |
| + t.Helper() |
| + req, _ := http.NewRequest("GET", srv.URL+"/", nil) |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusBadGateway { |
| + t.Fatalf("%s answered %d, want a refused 502", host, resp.StatusCode) |
| + } |
| + } |
| + |
| + logs := captureShipLog(t) |
| + ctx := context.Background() |
| + s := &keyLog{Store: release.NewDirStore(t.TempDir())} |
| + pending := map[applog.AppKey][]apperr.Record{} |
| + for round := 0; round < 2; round++ { |
| + get("proof.example.com") |
| + get("web.example.com") |
| + errshipGather(sink, pending) |
| + if _, ok := pending[applog.AppKey{Acct: "bab", App: ""}]; !ok { |
| + t.Fatalf("round %d: the edge did not file the app-less route under (bab, \"\"); pending = %v", round, pending) |
| + } |
| + errshipFlush(ctx, s, pending) |
| + if len(pending) != 0 { |
| + t.Fatalf("round %d: pending after flush = %v; a refused key must drop, not hold forever", round, pending) |
| + } |
| + } |
| + for _, k := range errshipKnownApps(pending, sink) { |
| + chunkPrune(ctx, s, applog.FamilyErrors, "leo-climbing", k, time.Now()) |
| + } |
| + |
| + var webPuts int |
| + for _, op := range s.all() { |
| + if strings.Contains(op, "//") { |
| + t.Fatalf("touched a key with an empty segment: %s", op) |
| + } |
| + if strings.HasPrefix(op, "put apps/bab/web/errors/leo-climbing/") { |
| + webPuts++ |
| + } |
| + } |
| + if webPuts != 2 { |
| + t.Fatalf("web chunks written = %d, want 2 (one per round): %v", webPuts, s.all()) |
| + } |
| + if n := strings.Count(logs.String(), `errors: not shipping for account "bab" app ""`); n != 1 { |
| + t.Fatalf("refusal logged %d times, want once:\n%s", n, logs.String()) |
| + } |
| +} |
| + |
| +// TestLogshipAndAnalyticsDropAppLessRecords: the logs and analytics |
| +// shippers take the same refusal: drop, never re-queue, write nothing. |
| +func TestLogshipAndAnalyticsDropAppLessRecords(t *testing.T) { |
| + logs := captureShipLog(t) |
| + ctx := context.Background() |
| + s := &keyLog{Store: release.NewDirStore(t.TempDir())} |
| + bad := applog.AppKey{Acct: "bab", App: ""} |
| + |
| + pending := map[applog.AppKey][]applog.Record{bad: {{TS: time.Now(), Box: "box1", Seq: 1, Msg: "x"}}} |
| + logshipFlush(ctx, s, pending) |
| + if len(pending) != 0 { |
| + t.Fatalf("logship pending = %v", pending) |
| + } |
| + chunkPrune(ctx, s, applog.FamilyLogs, "box1", bad, time.Now()) |
| + |
| + now := time.Date(2026, 10, 16, 6, 46, 0, 0, time.UTC) |
| + am := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now }) |
| + am.Observe(analytics.Obs{Acct: "bab", App: "", Host: "proof.example.com", Method: "GET", Status: 502, Path: "/", When: now}) |
| + am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "web.example.com", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now}) |
| + none := func() map[string]usage.EdgeRow { return nil } |
| + for i := 0; i < 2; i++ { // a second flush proves the bad delta was not re-queued |
| + if err := usageFlush(ctx, s, s, "box1", none, am, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + var webDay bool |
| + for _, op := range s.all() { |
| + if strings.Contains(op, "//") { |
| + t.Fatalf("touched a key with an empty segment: %s", op) |
| + } |
| + if op == "put apps/bab/web/analytics/box1/days/2026-10-16.json" { |
| + webDay = true |
| + } |
| + } |
| + if !webDay { |
| + t.Fatalf("the valid app's day file was not written: %v", s.all()) |
| + } |
| + out := logs.String() |
| + if strings.Count(out, `logs: not shipping for account "bab" app ""`) != 1 || strings.Count(out, `analytics: not shipping for account "bab" app ""`) != 1 { |
| + t.Fatalf("want one refusal line per family:\n%s", out) |
| + } |
| + if strings.Contains(out, "re-queued") { |
| + t.Fatalf("a refused analytics delta was re-queued:\n%s", out) |
| + } |
| +} |
diff --git a/internal/host/errship.go b/internal/host/errship.go| index 005a936b7..97932ab5c 100644 |
| --- a/internal/host/errship.go |
| +++ b/internal/host/errship.go |
| @@ -81,7 +81,12 @@ func errshipFlush(ctx context.Context, s release.Store, pending map[applog.AppKe |
| delete(pending, k) |
| continue |
| } |
| - key := apperr.ChunkKey(k.Acct, k.App, recs[0].Box, apperr.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + key, err := apperr.ChunkKey(k.Acct, k.App, recs[0].Box, apperr.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + if err != nil { |
| + unshippable(applog.FamilyErrors, k, err) |
| + delete(pending, k) |
| + continue |
| + } |
| if _, err := s.Put(ctx, key, body, ""); err != nil { |
| log.Printf("agent: errship flush %s/%s: %v (%d records held)", k.Acct, k.App, err, len(recs)) |
| continue |
diff --git a/internal/host/logship.go b/internal/host/logship.go| index a0edab3b3..c876cf621 100644 |
| --- a/internal/host/logship.go |
| +++ b/internal/host/logship.go |
| @@ -17,6 +17,7 @@ import ( |
| "errors" |
| "log" |
| "os" |
| + "sync" |
| "time" |
| "github.com/carlosframework/platform/internal/applog" |
| @@ -124,7 +125,12 @@ func logshipFlush(ctx context.Context, s release.Store, pending map[applog.AppKe |
| delete(pending, k) |
| continue |
| } |
| - key := applog.ChunkKey(k.Acct, k.App, recs[0].Box, applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + key, err := applog.ChunkKey(k.Acct, k.App, recs[0].Box, applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + if err != nil { |
| + unshippable(applog.FamilyLogs, k, err) |
| + delete(pending, k) |
| + continue |
| + } |
| if _, err := s.Put(ctx, key, body, ""); err != nil { |
| log.Printf("agent: logship flush %s/%s: %v (%d records held)", k.Acct, k.App, err, len(recs)) |
| continue |
| @@ -159,7 +165,11 @@ func logshipKnownApps(pending map[applog.AppKey][]applog.Record, sink *applog.Si |
| // subtree's key-embedded sizes total over the byte cap. Errors are logged |
| // and left for the next hourly pass — retention is idempotent. |
| func chunkPrune(ctx context.Context, s release.Store, fam applog.Family, box string, k applog.AppKey, now time.Time) { |
| - prefix := applog.FamilyBoxPrefix(fam, k.Acct, k.App, box) |
| + prefix, err := applog.FamilyBoxPrefix(fam, k.Acct, k.App, box) |
| + if err != nil { |
| + unshippable(fam, k, err) |
| + return |
| + } |
| hourDirs, err := s.ListDirs(ctx, prefix) |
| if err != nil { |
| log.Printf("agent: logship prune %s: %v", prefix, err) |
| @@ -209,3 +219,26 @@ func chunkPrune(ctx context.Context, s release.Store, fam applog.Family, box str |
| total -= h.bytes |
| } |
| } |
| + |
| +// unshippableSeen remembers which (family, app) pairs unshippable has |
| +// already reported, so a route that can never ship says so once per |
| +// process rather than every tick. Bounded by the routes a box serves. |
| +var unshippableSeen sync.Map |
| + |
| +type unshippableKey struct { |
| + fam applog.Family |
| + app applog.AppKey |
| +} |
| + |
| +// unshippable reports, once per family and app, that the family's key |
| +// builder refused the app (applog.ErrBadKeySegment): the records are |
| +// dropped, and no write or prune happens for it. The case that put this |
| +// here is a route with no app, filed under its account with an empty app |
| +// name; writing it would make apps/<sqid>//..., which hostops.Apps refuses |
| +// to walk. |
| +func unshippable(fam applog.Family, k applog.AppKey, err error) { |
| + if _, dup := unshippableSeen.LoadOrStore(unshippableKey{fam, k}, true); dup { |
| + return |
| + } |
| + log.Printf("agent: %s: not shipping for account %q app %q, dropping its records (logged once): %v", fam, k.Acct, k.App, err) |
| +} |
diff --git a/internal/host/logship_test.go b/internal/host/logship_test.go| index 331a7c62e..d6792c33a 100644 |
| --- a/internal/host/logship_test.go |
| +++ b/internal/host/logship_test.go |
| @@ -32,7 +32,7 @@ func TestLogshipFlushWritesOneChunkPerApp(t *testing.T) { |
| } |
| for _, app := range []applog.AppKey{{Acct: "bde", App: "ledgerpay"}, {Acct: "bab", App: "kass"}} { |
| - keys, err := s.List(context.Background(), applog.Prefix(app.Acct, app.App)) |
| + keys, err := s.List(context.Background(), mustKey(t)(applog.Prefix(app.Acct, app.App))) |
| if err != nil || len(keys) != 1 { |
| t.Fatalf("chunks for %v: %v (%v)", app, keys, err) |
| } |
| @@ -87,7 +87,7 @@ func TestLogshipPrune(t *testing.T) { |
| k := applog.AppKey{Acct: "bde", App: "ledgerpay"} |
| put := func(box, hour string, seq uint64, size int) string { |
| - key := applog.ChunkKey(k.Acct, k.App, box, hour, seq, size) |
| + key := mustKey(t)(applog.ChunkKey(k.Acct, k.App, box, hour, seq, size)) |
| if _, err := s.Put(ctx, key, make([]byte, size), ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -121,7 +121,7 @@ func TestLogshipPruneKeepsRecentUnderCap(t *testing.T) { |
| ctx := context.Background() |
| now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) |
| k := applog.AppKey{Acct: "bde", App: "ledgerpay"} |
| - key := applog.ChunkKey(k.Acct, k.App, "box1", "2026-08-10-11", 1, 10) |
| + key := mustKey(t)(applog.ChunkKey(k.Acct, k.App, "box1", "2026-08-10-11", 1, 10)) |
| if _, err := s.Put(ctx, key, make([]byte, 10), ""); err != nil { |
| t.Fatal(err) |
| } |
| @@ -133,3 +133,15 @@ func TestLogshipPruneKeepsRecentUnderCap(t *testing.T) { |
| t.Fatalf("key layout drifted: %s", key) |
| } |
| } |
| + |
| +// must unwraps a key builder's (key, error) for fixtures whose names are |
| +// known good: mustKey(t)(ChunkKey(...)). |
| +func mustKey(t testing.TB) func(string, error) string { |
| + return func(k string, err error) string { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return k |
| + } |
| +} |
diff --git a/internal/host/usage_loop.go b/internal/host/usage_loop.go| index 9ec1d81d8..77442159a 100644 |
| --- a/internal/host/usage_loop.go |
| +++ b/internal/host/usage_loop.go |
| @@ -8,6 +8,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/analytics" |
| + "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/usage" |
| ) |
| @@ -29,7 +30,13 @@ func usageFlush(ctx context.Context, s, raw release.Store, box string, drain fun |
| failed := map[analytics.AppKey][]analytics.Delta{} |
| for k, ds := range am.Drain() { |
| for _, d := range ds { |
| - if uerr := analytics.UpsertDay(ctx, raw, k.Acct, k.App, d); uerr != nil { |
| + uerr := analytics.UpsertDay(ctx, raw, k.Acct, k.App, d) |
| + if errors.Is(uerr, applog.ErrBadKeySegment) { |
| + // No key, so no retry can ever land it: drop, never re-queue. |
| + unshippable(applog.FamilyAnalytics, k, uerr) |
| + continue |
| + } |
| + if uerr != nil { |
| log.Printf("agent: analytics flush %s/%s %s: %v (re-queued)", k.Acct, k.App, d.Date, uerr) |
| failed[k] = append(failed[k], d) |
| } |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index ba4764854..c241be44b 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -786,6 +786,14 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-home-part2.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay fix-empty-app-key (RULING OWED, platform-1e), loaded |
| + // last: envelope-only, at main's dispositions. The apps/<sqid>/<app>/ |
| + // family key builders refuse an empty or invalid segment, and the box |
| + // shippers skip a refused app. No root-read unit changed or was added, so |
| + // the seal below is home-part2's. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| seal = "5d1b9b6addf6c9964ed18b417ee5b3d0d49fd43e1acabb775e054bba4b6e314e" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 8af5ccb11..9df7fd0e1 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -650,7 +650,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
| - {"internal/analytics/day.go", "UpsertDay", "nonrecord", "sel:Put", "c29f6c1c71ea18299291c1167e01c6aa9844f07acb52a2771ec62fa327c71d88"}, |
| + // Re-digested for fix-empty-app-key (census-ruling-fix-empty-app-key, platform-1e 2026-10-16): DayKey now returns an error for an empty or invalid account, app, box or date, and UpsertDay returns it before any Get or Put. Same single Put of the app-day file; no record key. |
| + {"internal/analytics/day.go", "UpsertDay", "nonrecord", "sel:Put", "ab892656cdbce1c80f0c33441341cf9c94dd22f06716ffffbd6c025100adb1b8"}, |
| {"internal/appconfig/configack.go", "WriteConfigAck", "nonrecord", "sel:Put", "211c8417bdbd6c6721fc503db085e9df073adeba623d9daa1e226554760ef5d1"}, |
| {"internal/appconfig/generation.go", "claimRawTouchGeneration", "nonrecord", "sel:Put", "313a5de17aee1b9ed6a344f6b785637c6978b60552a2243ef208c28eb8401a02"}, |
| {"internal/appconfig/store.go", "applyAt", "nonrecord", "sel:Put", "d234e359f44951240e6c052d87a17587845fb263ef9499dae39dae6c5c763e47"}, |
| @@ -760,9 +761,11 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/claimretire_transport.go", "retirementAcknowledgement", "nonrecord", "sel:Put", "5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416"}, |
| // D1 compares the retained registration's record key to the fresh claim; no record write. |
| {"internal/host/rootexecution_authority.go", "ClaimRootHTTP.executionAuthority", "key-site", "key:RecordKey", "107989f561409fd8c11c2666d0672b8dbeb368842fe00ad6a7b84fece2150438"}, |
| - {"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "82831c08563eab44de07c1c69752555eafe60c70dbf9521d9a4711bc5c104a6b"}, |
| + // Re-digested for fix-empty-app-key (census-ruling-fix-empty-app-key, platform-1e 2026-10-16): a chunk key the errors builder refuses (empty or invalid account or app) drops that app's records and logs once; no Put happens for it. Same single Put of the errors chunk; no record key. |
| + {"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "b340b3dede6d5cab08a13a2ee8dbef5bb6dfb4943264e9835489292c479a379d"}, |
| {"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"}, |
| - {"internal/host/logship.go", "logshipFlush", "nonrecord", "sel:Put", "a2fa594c1206028af170a2d0cbedef55ba00a2f1c698a6853e1214cc3b172dd9"}, |
| + // Re-digested for fix-empty-app-key (census-ruling-fix-empty-app-key, platform-1e 2026-10-16): as errshipFlush, for the logs family. Same single Put of the log chunk; no record key. |
| + {"internal/host/logship.go", "logshipFlush", "nonrecord", "sel:Put", "214a52ecc3bef193b8edcd6998cdaa75da182d552f4e605f68a5dededa4ffb68"}, |
| {"internal/host/metrics_loop.go", "putAlertState", "nonrecord", "sel:Put", "d5b6e8b34dc5a731d6bbafeac0b7d0c5c20706d6f413dff38088b3321b35a26c"}, |
| // Clears an aborting record through SetRecordMove (MoveGuardedAbortComplete) once nothing is owed under the op, then ReleaseAbort: record first, ruling M4. |
| {"internal/host/movesabort.go", "movesWorker.abortSource", "record-writer", "key:SetRecordMove", "51bafe02fe16f07c1f1e08cfbc224ba21c8277ec065fff1142349afe0dde675b"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv b/internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv| new file mode 100644 |
| index 000000000..566baca57 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-fix-empty-app-key.tsv |
| @@ -0,0 +1,7 @@ |
| +# Plan 14 census overlay fix-empty-app-key (branch fix-empty-app-key, 2026-10-16; loaded last, after home-part2). RULED by the Plan 14 controller 2026-10-16 as census-ruling-fix-empty-app-key. Envelope-only, at main's dispositions: no root-read unit is added, removed or changed, so no row and no frozen declaration moves, and the legacy seal stands. |
| +# The fix: the apps/<sqid>/<app>/{logs,errors,analytics}/ key builders (internal/applog FamilyPrefix, FamilyBoxPrefix, FamilyHourPrefix, FamilyChunkKey and their logs, errors and analytics wrappers, and analytics DayKey) return an error, applog.ErrBadKeySegment, for an empty or invalid account or app, or an empty or slash-bearing box, hour or date. They never build a key with an empty segment. An app-less route on the dev box leo-climbing (proof.dev.carlosframework.com: kind instance, account bab, app "", an Addr upstream nobody listens on) had its refused 502s filed under (bab, "") and shipped to apps/bab//errors/, which hostops.Apps refuses to walk. |
| +# Envelopes: internal/applog gains the checks (CheckApp, CheckSegment, ErrBadKeySegment) and ReadWindow returns the builder's error. internal/apperr and internal/analytics change their builder signatures, and their readers (apperr.ReadWindow, analytics.ReadRange) and analytics.UpsertDay return the error before any store call. internal/host's logshipFlush, errshipFlush, chunkPrune and usageFlush skip a refused app and log once (unshippable, unshippableSeen); usageFlush drops a refused analytics delta instead of re-queueing it. None of these reads anything new, from the box or the bucket; the only reads that change are ones a refused key no longer makes. |
| +@coverage internal/analytics e7461d39edd1660289669fdb5185f5231ef52e380978ce1d5765e91878f2c907 |
| +@coverage internal/apperr 8d2d53a1065bcdbdacdc178723b132746d00e580fe056bdc8ff2a5634f2b03d5 |
| +@coverage internal/applog fa9fd3a53e62d989d7fd834c86da8e784a396a991a33fa934f79b2db506dc669 |
| +@coverage internal/host 498a7d4be8bd9c2b566daa4536698b9eee14be7aa6ae14ccd14dba33a88018e1 |
diff --git a/logs_e2e_test.go b/logs_e2e_test.go| index c252c08d6..d0939784f 100644 |
| --- a/logs_e2e_test.go |
| +++ b/logs_e2e_test.go |
| @@ -89,7 +89,10 @@ func TestE2ELogsThroughConsole(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), seq, len(body)) |
| + key, err := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), seq, len(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if _, err := relStore.Put(context.Background(), key, body, ""); err != nil { |
| t.Fatal(err) |
| } |