Skip to content

fix(autoscale): honour --region instead of silently ignoring it - #775

Merged
scttfrdmn merged 1 commit into
mainfrom
fix/774-autoscale-region
Oct 9, 2026
Merged

scttfrdmn merged 1 commit into
mainfrom
fix/774-autoscale-region

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Fixes #774.

The root --region flag is advertised in spawn autoscale --help as overriding
SPORE_REGION/AWS_REGION and the shared config. It overrode nothing — every
call site built its config with a bare config.LoadDefaultConfig(ctx), so the
region came from the ambient chain. Ignored in both directions, with the env
beating the flag that claims to beat it:

$ spawn autoscale status --region us-east-1     # probed us-west-2
$ AWS_REGION=us-east-1 … --region us-west-2     # used us-east-1

Four sites, not the two I reported on the issue

The new gate found two I had missed, and they are the worse two:

site what it builds
autoscale_helpers.go getAutoscaler EC2/DynamoDB/SQS/CloudWatch
autoscale_helpers.go triggerLambda invokes the orchestrator by name
autoscale_status.go runAutoscaleHealth EC2 — cfg, _ :=, error discarded
autoscale_lifecycle.go runAutoscaleTerminate EC2 — same

autoscale terminate is the one that matters. With the wrong region it finds
nothing to terminate and reports success while the instances keep billing — a
cost-safety failure, and the inverse of the leak the reaper exists to prevent.
health reported a healthy group as having no instances. The DynamoDB
ResourceNotFoundException I originally led with was merely the most visible
symptom, not the most serious one.

All four now route through one autoscaleConfig helper that calls
aws.NewClientWithRegion(ctx, spawnRegion) — the resolution every other command
uses, which also fixes the shared profile these sites ignored.

This replaces #773's mitigation with a real fix: the coverage probe used the same
bare call, which is why it reported "no autoscale orchestrator runs in this
account" against an account that has one. Naming the probed region made that
legible; sharing the resolver makes it correct.

The gate

TestAutoscaleHonoursTheRegionFlag forbids an unparameterised
LoadDefaultConfig in cmd/autoscale*.go.

A source assertion deliberately: proving a region reached the SDK needs a live
account or an injected client, neither of which exists on this path today, and
forbidding the construct that caused it is cheap and sufficient.
config.LoadDefaultConfig(ctx, config.WithRegion(r)) does not match — it
bans the bug, not the function.

It skips comment lines, because the explanation above names the broken call and
three gates this session passed against their own prose.

Confirmed to fail on a compiling revert. My first attempt reverted the call
without restoring the import, so the package didn't build and the gate couldn't
run — a build failure is not a passing gate, which is the second time that trap
came up today.

Verified live, both directions

$ spawn autoscale status --region us-east-1
No active autoscale groups
autoscale orchestrator: spawn-autoscale-orchestrator-staging invoked by … (rate(1 minute))

$ spawn autoscale status --region us-west-2
⚠️  no autoscale orchestrator found in us-west-2 …

The ResourceNotFoundException is gone, and the absence report is now genuinely
about the region asked for.

Fixes #774.

The root --region flag is advertised in `spawn autoscale --help` as
overriding SPORE_REGION/AWS_REGION and the shared config. It overrode
nothing: every call site built its config with a bare
config.LoadDefaultConfig(ctx), so the region came from the ambient chain.
Ignored in BOTH directions, with the env beating the flag that claims to
beat it:

    $ spawn autoscale status --region us-east-1     # probed us-west-2
    $ AWS_REGION=us-east-1 … --region us-west-2     # used us-east-1

## Four sites, not the two I reported

The gate found two I had missed, and they are the worse two:

  autoscale_helpers.go  getAutoscaler      (EC2/DynamoDB/SQS/CloudWatch)
  autoscale_helpers.go  triggerLambda      (invokes by name)
  autoscale_status.go   runAutoscaleHealth (cfg, _ := …, error discarded)
  autoscale_lifecycle.go runAutoscaleTerminate (same)

**autoscale terminate is the one that matters.** With the wrong region it
finds NOTHING to terminate and reports success while the instances keep
billing — a cost-safety failure, and the inverse of the leak the reaper
exists to prevent. `health` reported a healthy group as having no
instances. The DynamoDB ResourceNotFoundException was merely the visible
symptom.

All four now route through autoscaleConfig, which goes through
aws.NewClientWithRegion(ctx, spawnRegion) — the same resolution every
other command uses, and which fixes the shared PROFILE these sites also
ignored.

This also replaces the mitigation in #773 with a real fix: the coverage
probe used the same bare call and so reported "no autoscale orchestrator
runs in this account" against an account that has one. Naming the probed
region made that legible; sharing the resolver makes it correct.

## The gate

TestAutoscaleHonoursTheRegionFlag forbids an unparameterised
LoadDefaultConfig in cmd/autoscale*.go. A source assertion on purpose:
proving a region reached the SDK needs a live account or an injected
client, neither of which exists on this path, and forbidding the
construct that caused it is cheap and sufficient.
`config.LoadDefaultConfig(ctx, config.WithRegion(r))` does not match, so
it bans the bug rather than the function.

It skips comment lines, because the explanation above names the broken
call and three gates this session passed against their own prose.

Confirmed to fail on a COMPILING revert. The first attempt reverted the
call without restoring the import, so the package did not build and the
gate could not run — a build failure is not a passing gate, which is the
second time that trap came up today.

Verified live in both directions: --region us-east-1 now lists groups and
finds the orchestrator; --region us-west-2 genuinely reports its absence
there.
@codecov

codecov Bot commented Oct 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/autoscale_helpers.go 0.00% 8 Missing ⚠️
cmd/autoscale_lifecycle.go 0.00% 3 Missing ⚠️
cmd/autoscale_status.go 0.00% 3 Missing ⚠️
cmd/autoscale_coverage.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@scttfrdmn
scttfrdmn merged commit d373ace into main Oct 9, 2026
7 checks passed
@scttfrdmn
scttfrdmn deleted the fix/774-autoscale-region branch October 9, 2026 16:25
scttfrdmn added a commit that referenced this pull request Oct 10, 2026
`spawn` is not only a CLI. 966362334030/us-east-1 holds 18 Lambda
functions, 23 DynamoDB tables, 21 S3 buckets, 16 IAM roles, 18 log groups
and 5 EventBridge rules — 103 control-plane resources — and nothing
documented what they were or which code owned them.

Every figure is MEASURED from the live account (2026-10-09) via `spawn
footprint` and the AWS API, with the re-measure commands in the page. That
matters because the answer was previously reconstructed from memory and
three attempts at it were each incomplete: #653's inventory omitted log
groups entirely, a prefix-filtered query found 10 of 14, and a bucket list
missed an 11-bucket family.

What the page records that the source does not make obvious:

- **How each function is invoked** — EventBridge schedule, API Gateway, or
  direct CLI invoke. The last explains why four functions have no resource
  policy at all, which otherwise reads like a misconfiguration.
- **Which live functions come from OTHER repos** (lagotto, spore-host
  umbrella, spawn-ts, prism). An unfamiliar function name in that account
  is not necessarily an orphan, and treating it as one would be a mistake.
- **lambda/alert-handler/ has no live function anywhere.** It builds and
  vets in CI via the nested-module loop, but nothing runs it. Flagged for
  a decision rather than left ambiguous.
- Both `spawn-autoscale-groups-*` tables are EMPTY — no autoscale group
  has ever been configured, which is the context for #772.
- `spawn-ts-portal` is the only non-Go runtime (nodejs20.x).
- The deploy traps, each of which cost a real failure: the space in
  `--parameter-overrides`, a parameter omitted there resetting to the
  template default (#650), SAM's `Enabled: !Ref` always resolving to
  ENABLED, the CFN-generated role ARN in the cross-account trust (#476),
  and autoscale ignoring --region until #775.

One suspicion checked and NOT reported as a defect: the
`spawn-binaries-<region>` buckets hold 538–803 objects, which looked like
uneven replication of the agent. It is historical backfill — us-east-1 and
us-west-2 carry 130 released versions, ap-northeast-1 carries 80 — and
0.126.1 plus spored-windows-amd64.exe are present in every region checked,
so bootstrap is unaffected. A missing agent in one region would have been
a silent regional failure, so it was worth verifying rather than asserting
either way.

Referenced from CLAUDE.md, since the thing most likely to go wrong is
changing something under lambda/ without knowing the control plane has no
reaper and spans several repos.
scttfrdmn added a commit that referenced this pull request Oct 11, 2026
…hey used (#772) (#822)

BREAKING: 13 `spawn autoscale` subcommands removed, plus 13 exported
members of pkg/aws. Next release bumps MINOR.

The production orchestrator reconciled on rate(1 minute) from 2026-02-14 —
roughly 350,000 invocations — against a table that has NEVER held a
record. Both groups tables measured Count=0 at deletion.

## Why removal rather than tightening the role

cloudformation/autoscale-ec2-role.yaml held, and the live role confirmed:

  ec2:RunInstances, ec2:TerminateInstances, ec2:CreateTags  ->  *
  iam:PassRole                                              ->  *
      Condition: iam:PassedToService = ec2.amazonaws.com

iam:PassedToService constrains the SERVICE, not the role. With
RunInstances on * in the same document, the holder launches an instance
carrying any EC2-assumable role in the account and reads its credentials
from IMDS. Trust was arn:aws:iam::966362334030:root — the whole infra
account — gated by an sts:ExternalId that is published in the orchestrator
template.

Bounded today: no Administrator/PowerUser role in that account trusts
ec2.amazonaws.com, so there is nothing privileged to reach. It becomes
serious the moment someone creates one — which is the argument for
deleting an unused primitive rather than trusting the role inventory to
stay benign. The TerminateInstances on * was the more immediate problem:
it could kill any instance in the compute account.

## Live removal

Three CloudFormation stacks deleted, all DELETE_COMPLETE:

  spawn-autoscale-orchestrator-production   function, role, rule,
  spawn-autoscale-orchestrator-staging      permission, log group, table
  spawn-autoscale-ec2-role  (compute acct)  the role

EVERYTHING was CloudFormation-managed, including that role. My first
instinct was to delete the role directly; describe-stack-resources showed
it belonged to a stack, so direct deletion would have caused drift and it
would have come back. I verified that stack owned exactly one resource
before deleting it.

Control plane re-measured with `spawn footprint`: 100 -> 90. Not
decremented by hand — an earlier edit of that page did the arithmetic
manually and was wrong.

## A live route nearly went with it

handleGetCostSummary and calculateInstanceCosts lived in
lambda/dashboard-api/autoscale.go and have nothing to do with autoscale.
Deleting the file wholesale silently removed /api/cost-summary; the build
caught it. Moved to cost_summary.go VERBATIM — changing cost arithmetic
inside a removal commit would hide a behaviour change in a mechanical one.

That move surfaced a finding, documented in place rather than fixed: the
handler runs on a hardcoded, UNDATED, us-east-1-only price table that
defaults an unknown instance type to a fabricated $0.10/hr, then
multiplies by 730 and shows it as a monthly estimate. truffle#114's rule
is that a fabricated price is worse than no price. Tracked in truffle#184.

## The API-surface gate earned its place

TestPublicAPISurface failed and named the 13 removed exported members and
the three consumer repos. Verified zero consumers two ways — local
checkouts of spore-host-mcp, lagotto and calque, and an org-wide code
search — before accepting the break and running `make api-snapshot`.

Those symbols were added THIS AFTERNOON in #774/#775 to warn when an
autoscale orchestrator ran without coverage. That question no longer
exists, so they go too.

## Also resolved

spawn-registry-production grants removed from two IAM scripts. That table
was already dead: dynamoRegistryTable was declared in autoscale.go and
referenced nowhere, so the dashboard held access to a registry it never
read. Found by the data-chain audit; removed here as a side effect.

Four stale figures fixed, all mine: CLAUDE.md's function count (stale
twice over), the Makefile's hand-written module count (now a discovery
statement — the count had drifted 12 -> 10), and two wrong claims in
docs/infra-account.md.

nested-modules.sh reports 9 modules now, down from 10, noticed by
discovery with no code change.

make check green, check-docs, census, changelog, semgrep 0 findings, Tier
0 e2e with -count=1 against a fresh binary (68s).
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.

spawn autoscale advertises --region and ignores it (ambient region only)

1 participant