Skip to content

LPX-604: headless mode — skip ALB / listeners / Route53 for apps without a public face#29

Merged
stevethomas merged 14 commits into
mainfrom
steve/lpx-604-headless-mode-skip-alb-listeners-route53-for-apps-without-a
May 19, 2026
Merged

LPX-604: headless mode — skip ALB / listeners / Route53 for apps without a public face#29
stevethomas merged 14 commits into
mainfrom
steve/lpx-604-headless-mode-skip-alb-listeners-route53-for-apps-without-a

Conversation

@stevethomas
Copy link
Copy Markdown
Member

@stevethomas stevethomas commented May 19, 2026

Hey, I made a thing! 🥳

LPX-604

What problems are you solving?

Three additions on top of the original headless work — bundled because each lives in Command::execute() or its immediate neighbours, and review boundaries get blurry otherwise.

Headless mode

Apps with no domain, no apex, and no tenant declaring either now deploy without an ALB / target group / listeners / hosted zone / SSL cert / Route 53 record set. The ECS service runs without a load balancer association.

  • Manifest::isHeadless() — true when solo + no domain/apex, or multitenant + every tenant headless.
  • Contracts/ExecutesWebStep — marker on every step that touches public-facing infrastructure.
  • Applied to SyncLoadBalancerStep, SyncTargetGroupStep, SyncHttpListenerStep, SyncHttpsListenerStep, SyncListenerRuleStep, SyncHostedZoneStep, SyncSslCertificateStep, SyncSoloRecordSetStep.
  • ChecksIfCommandsShouldBeRunning skips ExecutesWebStep instances when headless.
  • SyncEcsServiceStep drops loadBalancers and healthCheckGracePeriodSeconds from the create/update payloads when headless. Drift detection still covers desiredCount. needsUpdate() / createPayload() / updatePayload() extracted as pure helpers.
  • Manifest::tenants() no longer TypeErrors on a headless tenant entry.

Manifest integrity

Four early gates in Command::execute() that bail with a clear message:

  • name declared (top-level — drives every keyed AWS resource name)
  • aws.region declared (load-bearing for every SDK client at construction)
  • aws.account-id declared
  • aws.account-id matches sts:GetCallerIdentity for the resolved YOLO_<ENV>_AWS_PROFILE

Aws::profileAccountId() added next to Aws::accountId() for symmetry.

Cherry-picked guard

The AWS account-mismatch guard (originally steve/yolo-aws-account-mismatch-guard) is part of this PR — single STS call before handle(), fails fast when the manifest's expected account doesn't match the resolved profile. InitCommand short-circuits before registerAwsServices(), so new apps don't trip on yolo init.

Is there anything the reviewer needs to know to deploy this?

  • No behaviour change for existing non-headless apps. Marker-based gates only fire when Manifest::isHeadless() === true. Integrity checks were always implicit — the failures they now catch were previously deep AWS SDK errors.
  • Existing manifests must declare name, aws.region, aws.account-id. Coding Labs marketing site, Convict Records, LP-staging-eventual all already do; no migration needed. A hand-edited manifest missing any of these would now bail at startup instead of erroring midway through a sync.
  • Headless apps still use the tasks.web manifest shape. The container is named web in the task def even when it never binds an HTTP port. Proper tasks.queue / tasks.scheduler task types are tracked separately (LPX-580+).
  • 119 pest, 0 phpstan, pint clean.

🤖 Generated with Claude Code

stevethomas and others added 14 commits May 19, 2026 23:15
…lic face

Detect headless apps from manifest shape (no `domain`, no `apex`, no
tenant declares either) and skip every step that touches a public-
facing AWS resource: ALB, target group, HTTP/HTTPS listeners, listener
rule, hosted zone, SSL cert, Route 53 record set. The ECS service
still runs without a load balancer association.

Use cases: queue worker pools, scheduler-only apps, internal services
with no public surface.

Surface:

- `Manifest::isHeadless()` — true when neither `domain`/`apex` is set
  on the solo manifest, and (for multitenant) every tenant entry lacks
  both keys.
- `Contracts/ExecutesWebStep` — new marker for ALB-side steps. Applied
  to SyncLoadBalancerStep, SyncTargetGroupStep, SyncHttpListenerStep,
  SyncHttpsListenerStep, SyncListenerRuleStep.
- `ExecutesDomainStep` finally wired into the gate (was an unused
  marker, flagged in the PR #26 review). Applied to
  SyncSoloRecordSetStep alongside its existing ExecutesSoloStep.
- `ChecksIfCommandsShouldBeRunning` skips both markers under headless.
- `SyncEcsServiceStep` create/update payloads drop `loadBalancers`
  and `healthCheckGracePeriodSeconds` when headless. Extracted
  `needsUpdate()` / `createPayload()` / `updatePayload()` as pure
  helpers — also closes the test-coverage gap the PR #25 review
  flagged for this step.
- `Manifest::tenants()` no longer TypeErrors on a headless tenant
  entry — apex normalisation gracefully resolves to null instead of
  bombing on `str_starts_with(null, ...)`.

16 new unit tests pin the headless detector matrix, the
SyncEcsServiceStep drift predicate (incl. carry-over coverage gap from
PR #25 review), the headless conditional payload shape, and the
tenants() normaliser's headless tolerance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three step-skip conditions (solo-in-multitenant, multitenancy-in-solo,
public-step-in-headless) sat as two separate blocks. Folding into one
compound `if` matches the shape of the check (all are "skip when
manifest mode disagrees with the marker") and makes the skip surface
trivially extensible for the next marker we add.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two markers for "stuff that only matters with a public face" was YAGNI
— no real step is web-but-not-domain or domain-but-not-web. Headless
mode skips the lot uniformly.

* ExecutesWebStep is now the sole public-facing marker.
* ExecutesDomainStep contract deleted.
* SyncHostedZoneStep, SyncSslCertificateStep, SyncSoloRecordSetStep
  re-tagged with ExecutesWebStep.
* ChecksIfCommandsShouldBeRunning skip-clause halves in size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous compound-skip block lumped three different concerns into
one OR-chain. Tenancy-mode mismatch (solo step in mt app, mt step in
solo app) and "is this a public-facing step in a headless app" are
orthogonal — readability wins from separation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before any AWS-touching command runs handle(), verify the manifest's
aws.account-id matches sts:GetCallerIdentity for the resolved profile.
Catches the foot-gun where YOLO_<ENV>_AWS_PROFILE points at the wrong
account and the manifest's ID gets used silently for ARN interpolation.

One STS call per command run (~50ms). Skips when the manifest doesn't
declare an account (legacy / test fixtures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ensureManifestAccountMatchesProfile()` returned bool + called error()
itself, while every other manifest-validation failure in the codebase
(Manifest::apex, Helpers::validate*, EnsureEnvIsConfiguredCorrectlyStep,
SyncListenerRuleStep::nextAvailablePriority, the SG-rule integrity
guards) throws IntegrityCheckException. Aligning with the prevailing
pattern: void return, throw on either STS lookup failure or account
mismatch, Symfony's exception renderer handles the styled output for
free.

DX wins: single responsibility (guard either succeeds or throws), the
caller drops the `if (! ...) return 1` shim, stack trace available with
-vvv for debugging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…countId helper

Two pieces of feedback:

1. Throwing IntegrityCheckException from Command::execute() broke the
   prevailing pattern at that layer — every other early gate (missing
   manifest, wrong environment, missing AWS_PROFILE) uses
   `error() + return 1`. The IntegrityCheckException throws all live
   deeper, in Steps and Manifest helpers. Reverted to bool return so
   the four early gates look identical.

2. Was reaching past Aws::accountId() to call Manifest::get() directly.
   Plus the STS side had no helper. Added Aws::profileAccountId()
   (named for the .env profile rather than 'caller', since the value
   is whatever YOLO_<ENV>_AWS_PROFILE resolves to) so both sides go
   through Aws::*.

Presence check now Manifest::has('aws.account-id') rather than the
truthy-check on Aws::accountId() — the latter has a `: string` return
type that TypeErrors when the key is absent, and widening to `?string`
would cascade into every existing caller using sprintf/concat.

InitCommand carve-out at Command::execute() short-circuits before
registerAwsServices(), so new apps don't hit the guard before they've
written a manifest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "skip when not declared" early-return was a silent foot-gun — same
one the whole guard exists to prevent. A manifest missing
aws.account-id would happily run against whatever YOLO_<ENV>_AWS_PROFILE
resolves to, with no safety net.

Now bails with a clear "must declare aws.account-id under
environments.<env>" message. InitCommand short-circuits before this
guard, so new apps run `yolo init` without tripping it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Split the manifest-vs-AWS reconciliation into two early gates in
Command::execute():

* ensureManifestIntegrity() runs *before* registerAwsServices(). Bails
  when any of `name`, `aws.region`, `aws.account-id` is missing.
  `aws.region` is the load-bearing one — RegistersAws feeds it into
  every SDK client at construction time, so a null region collapses
  into a deep AWS SDK error. `name` drives every keyedResourceName()
  and silently produces invalid AWS resource names (trailing hyphen)
  if absent. account-id presence is now also in this group.

* ensureAccountMatchesProfile() runs *after* registerAwsServices()
  and STS-verifies the manifest's account-id against
  YOLO_<ENV>_AWS_PROFILE. Renamed from ensureManifestAccountMatches-
  Profile (the manifest part is implied now that integrity runs
  upstream).

Test split: CommandManifestIntegrityTest (no AWS mocks needed) and
CommandAccountGuardTest (STS mock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ensureManifestIntegrity() reads as a series of named gates short-
circuited via && rather than a foreach loop with implicit semantics.
Each extracted method tells you what it's checking — the loop was
clever but obscured the per-key checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manifest::has() now falls back to top-level when an env-scoped lookup
misses. The dedicated ensureNameDeclared() method + its bespoke error
message were the only special-case for top-level keys; collapsing both
down keeps the integrity check a flat list of `Manifest::has` lookups.

Error message dropped the "under environments.<env>" qualifier — works
uniformly for top-level `name` and env-scoped `aws.*` keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rate

Widening Manifest::has() to fall back to top-level was solving one
caller's problem by relaxing the contract of a widely-used helper —
opens subtle ambiguity if a future env-scoped key ever shares a name
with a top-level one. Reverted; ensureNameDeclared() handles the
top-level `name` lookup directly via Manifest::current().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
routedHosts() comment block restated the ternary, and the tenants
normaliser comment took three lines to caption a one-line null
coalesce. Both code paths read for themselves.

Kept comments elsewhere that explain non-obvious *why* — un-memoised
SG cache (UsesEc2), AwsException translation (UsesEcs), narrow
service drift detection (SyncEcsServiceStep), headless tenant raw
read (Manifest::isHeadless).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* `// prefer the apex key when specified` — restated what `get(key, default)` already does.
* `// apex resolves to null for headless tenants` — the `?? ($config['domain'] ?? null)` chain already shows it.

Kept the one-line `// Read raw — tenants() normaliser TypeErrors on a
headless tenant.` in isHeadless() — that's a real why-not-use-tenants()
signal that disappears without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stevethomas stevethomas merged commit 7d60730 into main May 19, 2026
5 checks passed
@stevethomas stevethomas deleted the steve/lpx-604-headless-mode-skip-alb-listeners-route53-for-apps-without-a branch May 19, 2026 14:07
stevethomas added a commit that referenced this pull request May 20, 2026
LPX-604 (#29) added headless-mode gating for solo DNS via the
ExecutesWebStep interface, but missed the multitenancy mirror.
SyncMultitenancyRecordSetStep dereferences \$this->config['apex'] and
['domain'] unconditionally, so a headless multitenant deploy would
undefined-key-crash before reaching ECS.

Symmetric fix: implement ExecutesWebStep on the tenant step too, so
Manifest::isHeadless() drives the skip via the existing
ChecksIfCommandsShouldBeRunning::shouldBeRunning() trait.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stevethomas added a commit that referenced this pull request May 20, 2026
* feat(LPX-606): yolo deploy always builds; --app-version names the build

Restores build-in-deploy that v1's DeployCommand rewrite in #26 lost.
Drops the alpha-era assumption that `--app-version=<tag>` meant "deploy
this previously-built artefact" — that's rollback's job, not deploy's
(future LPX-608).

`--app-version` is now an input to the build: the tag to stamp on the
fresh image. Absent → BuildCommand generates a timestamp. Present (e.g.
a GitHub release name) → BuildCommand uses it verbatim.

Build always runs. BuildCommand writes --app-version back onto the
shared InputInterface, so deploy's downstream steps pick up the
freshly-baked tag via `$this->option('app-version')`.

Also drops the `Manifest::has('tasks.web')` guard. The interface
mechanism (ExecutesWebStep + Manifest::isHeadless) already gates DNS
steps for headless apps. The guard was catching a different scenario —
"yolo.yml has no Fargate spec at all" — which is best left to surface
as ServiceNotFoundException via the natural AWS error path.

README's `yolo init && yolo build && yolo sync production && yolo deploy
production` was nonsensical (`yolo build` errored on the missing env arg;
explicit build was redundant). Updated to:

    yolo init && yolo sync production && yolo deploy production

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(LPX-606): SyncMultitenancyRecordSetStep skips on headless

LPX-604 (#29) added headless-mode gating for solo DNS via the
ExecutesWebStep interface, but missed the multitenancy mirror.
SyncMultitenancyRecordSetStep dereferences \$this->config['apex'] and
['domain'] unconditionally, so a headless multitenant deploy would
undefined-key-crash before reaching ECS.

Symmetric fix: implement ExecutesWebStep on the tenant step too, so
Manifest::isHeadless() drives the skip via the existing
ChecksIfCommandsShouldBeRunning::shouldBeRunning() trait.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant