Skip to content

fix: stamp Go's base-identity tags, or refuse to launch (#51) - #60

Merged
scttfrdmn merged 1 commit into
mainfrom
fix/51-base-identity-tags
Aug 1, 2026
Merged

fix: stamp Go's base-identity tags, or refuse to launch (#51)#60
scttfrdmn merged 1 commit into
mainfrom
fix/51-base-identity-tags

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Closes #51. Part of #57.

The bug

A spawn-ts-launched instance was visible in spawn list yet invisible and unterminatable in the portal.

The portal filters on spawn:iam-user in three places — the instance list (lambda/dashboard-api/instances.go:60), single-instance lookup (:168), and terminate (:285, which 403s on a mismatch). spawn list filters on spawn:managed alone. So the divergence only surfaced when someone tried to clean up, and by then the instance had been billing the whole time. spawn cleanup --only-mine skips it too (pkg/aws/cleanup.go:93).

The fix

buildLaunchTags takes an optional LaunchIdentity and stamps Go's base-identity block: root, created-by, version, account-id, account-base36, iam-user, account-name (slugified, #121), plus os and local-username.

Identity arrives as data so the tag builder stays pure. EC2Provider resolves it once via GetCallerIdentity and caches it for the provider's lifetime, or accepts it from the caller — the federated BYOA path already has AssumedRoleUser.Arn and the account id back from AssumeRoleWithWebIdentity, so no extra call there.

It refuses to launch rather than degrade

Including the sneakier 200-with-empty-fields case, which a bare try/catch sails past and stamps undefined. Omitting the tag and launching anyway produces an orphaned billable instance nobody can terminate from the portal — strictly worse than a failed launch, and the #63 invariant in its most expensive form: the error must not look like an absence of identity. Both refusal paths assert nothing was launched, i.e. it fails before RunInstances, not after.

local-username is resolved once, for both the tag and user-data

They must agree or spawn connect SSHes to a user that doesn't exist — Go prefers the tag and falls back to ec2-user (cmd/connect.go:135), so a custom username with no tag silently sent it to ec2-user. (Caught by a test: the first cut snapshotted tagList before the mutation.)

spawn:active-ports

spored consumes it as the sibling of active-processes in one expression — countActiveSessions() + countActivePortConnections(config.ActivePorts) (pkg/agent/agent.go:402). Absent, a port-based activity signal was silently unenforceable: a box serving a live RStudio connection on 8787 with no matching process name idled out underneath the user.

Side-discovery: the tag budget was over AWS's hard limit

Found by measuring, not by reading. AWS caps a resource at 50 tags and exceeding it fails RunInstances outright — it doesn't truncate.

Go allows a flat 35 spawn:param:* tags with a comment claiming that stays "under AWS 50-tag limit" (pkg/aws/tags.go:247). The arithmetic doesn't hold — the sweep block is 4 tags and a configured launch carries ~30 more:

tags on a maximal sweep member
before this PR 64 — already over
with the identity block, flat 35 73
with a computed budget 49 (one slot reserved for local-username)

So the bug predates this change and the identity block would have worsened it. Rather than swap in a smaller guessed number, the cap is now a budget against what the launch actually consumed, so it stays correct as tags are added. Surplus params drop in sorted key order, so the surviving subset is deterministic rather than dependent on object insertion order. Dropping parameters is itself lossy — they're how a sweep member records which point in the space it is — but a truncated tag set beats a launch that fails.

The old tags.test.ts test asserted the flat 35, i.e. it asserted the bug; it now asserts the invariant. The new end-to-end maximal-launch test is the one that would actually have caught it.

docs/integration.md

#51 asks for this explicitly: the "wire-compatible" claim now names what is not stamped, in the same tier language as the rest of that section. buildTags can write 54 distinct keys; buildLaunchTags writes 43.

absent tier
fsx-* (7), efs-id, efs-mount-point, dcv-session-id, app-name D — provisioning/streaming the browser can't do
spawn:command B — observability only; user-data already carries the command. Must keep Go's len <= 256 guard or RunInstances fails (spawn#214/#246)
slack-workspace-id B — pending a workspace binding on the notify path
job-array-created E — deliberate: non-deterministic, no reader, launch-time covers it

It also records the two tags spawn-ts writes that Go's launcher doesn't:

  • spawn:compute-seconds — seeded at 0; Go lets spored create it (pkg/agent/agent.go:404), and pkg/provider/ec2.go:486 reads it either way.
  • spawn:idle-cpu — Go decodes it (pkg/provider/ec2.go:508) but never writes it at launch, so a Go-launched instance's --idle-cpu threshold is unreachable by the reader that wants it. spawn-ts writing it is the fix, not the divergence.

spawn:created-by is deliberately "spawn-ts", not "spawn" — no reader compares the value, and an operator benefits from knowing which launcher produced an instance.

Verification

npm run typecheck   # lib + demo tsconfigs, clean
npx vitest run      # 26 files, 395 passed

No AWS calls; the STS paths are stubbed via STSClient.prototype.send.

A spawn-ts launch was visible in `spawn list` yet invisible AND
unterminatable in the portal. The portal filters its instance list,
single-instance lookup and terminate on `spawn:iam-user`
(lambda/dashboard-api/instances.go:60/:168/:285 — the last 403s on a
mismatch); `spawn list` filters on `spawn:managed` alone. So the
divergence only surfaced when someone tried to clean up, and by then
the instance had been billing the whole time.

buildLaunchTags now takes an optional `LaunchIdentity` and stamps the
block Go writes: root, created-by, version, account-id, account-base36,
iam-user, account-name (slugified, #121), plus `os` and
`local-username`. Identity arrives as data so the tag builder stays
pure; EC2Provider resolves it once via GetCallerIdentity and caches it,
or takes it from the caller — the federated BYOA path already has the
ARN and account id back from AssumeRoleWithWebIdentity.

EC2Provider **refuses to launch** when it can't resolve the identity,
including the 200-with-empty-fields case a bare try/catch would sail
past. Omitting the tag and launching anyway produces an orphaned
billable instance nobody can terminate from the portal — strictly worse
than a failed launch, and the #63 invariant in its most expensive form:
the error must not look like an absence of identity.

`local-username` is resolved once and used for both the tag and
user-data. They must agree or `spawn connect` SSHes to a user that
doesn't exist (cmd/connect.go:135 prefers the tag, falls back to
ec2-user), so a custom username with no tag silently sent it to
ec2-user.

Also adds `spawn:active-ports`, which spored consumes as the sibling of
active-processes in one expression (pkg/agent/agent.go:402). Absent, a
port-based activity signal was silently unenforceable: a box serving a
live RStudio connection on 8787 with no matching process name idled out
underneath the user.

Fixes a tag-limit bug found while measuring the above, present in Go
too. AWS caps a resource at 50 tags and exceeding it fails RunInstances
outright rather than truncating. Go allows a flat 35 spawn:param:* tags
with a comment claiming that stays "under AWS 50-tag limit"
(pkg/aws/tags.go:247); the arithmetic doesn't hold — a maximal sweep
member reached 73, and 64 before this change, so the bug predates the
identity block and this would have worsened it. The cap is now a budget
computed against what the launch actually consumed (49 on a maximal
launch, one slot reserved for local-username), and surplus params drop
in sorted key order so the surviving subset is deterministic.

docs/integration.md's "wire-compatible" claim now names what is *not*
stamped, tiered like the rest of that section: the fsx/efs/dcv/app-name
keys as D, `spawn:command` and `slack-workspace-id` as B,
`job-array-created` as a deliberate E. It also records the two tags
spawn-ts writes that Go's launcher doesn't — `compute-seconds` (Go lets
spored create it) and `idle-cpu`, which Go *decodes*
(pkg/provider/ec2.go:508) but never writes, making a Go-launched
instance's --idle-cpu threshold unreachable by the reader that wants it.

Tests: 26 files, 395 passed. tags.test.ts's old "caps at 35" test
asserted the bug, so it now asserts the invariant instead, and a new
maximal-launch test is the one that would actually have caught it.

Part of #57.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

buildLaunchTags omits Go\s base-identity block — spawn:iam-user absence breaks portal terminate + per-user isolation

1 participant