feat(infra): build + pin the baked open-swe-base-arm64 AMI (T12 AMI / item 3)

Packer-build the custom base image and repoint AppService off the AL2023
placeholder onto it.

deploy/ami/open-swe-base.pkr.hcl — fix two bugs that blocked the first real
`packer build` (the config had only ever been `packer validate`'d at T8):
  - the file provisioner failed uploading the templates dir ('scp: …: Is a
    directory') — a trailing-slash contents-upload needs the dest dir to exist;
    added a 'mkdir -p /tmp/open-swe-templates' shell provisioner + dropped the
    dest trailing slash.
  - the shell provisioner's custom execute_command omitted {{ .Vars }}, so the
    environment_vars never reached provision.sh (which runs under set -u and
    aborted on CLOUDWATCH_AGENT_DEB_URL). Added {{ .Vars }}.

infra:
  - ami-cache.ts: BAKED_OPEN_SWE_AMI_ID = ami-0545363bb147229ff (built 2026-06-26
    from open-swe-base-arm64-20260626-201929) + bakedOpenSweArm64() pinning it by
    exact id via MachineImage.genericLinux (offline, deterministic). Dropped the
    now-dead AL2023 cachedInContext helper + context key; kept the EBS/replacement
    discipline docs.
  - app-service.ts: machineImage → bakedOpenSweArm64().
  - open-swe-stack.ts: output BakedAmiId (was the AL2023 PinnedAmiId guard).
  - cdk.context.json → {} (AMI is a static id pin; no context lookups remain).
  - README: Baked AMI + EBS-replacement-discipline section.

tsc + cdk synth(dev+prod) + jest(16) clean; template ImageId = the baked AMI.

NOTE: held — do NOT merge until the open-swe-dev secret values are populated
(put-config.sh). The infra CD is live, so merging this to dev auto-deploys
OpenSweDevStack; without secrets the box boots but fetch-config fail-fasts →
unhealthy ALB target on the shared prod ALB. Merge once secrets are set (T14).
This commit is contained in:
Adam Moussa 2026-06-26 16:31:58 -04:00
parent 70319cac0d
commit 8ed6268be2
6 changed files with 74 additions and 103 deletions

View file

@ -113,10 +113,17 @@ build {
name = "open-swe-base"
sources = ["source.amazon-ebs.open-swe"]
# Stage the boot-time templates and helper scripts into the image.
# Stage the boot-time templates into the image. The destination dir must exist
# BEFORE a trailing-slash (contents-only) file upload — packer's file provisioner
# does not create it, and uploading the directory itself trips scp ("Is a
# directory"). So mkdir first, then upload the contents into it.
provisioner "shell" {
inline = ["mkdir -p /tmp/open-swe-templates"]
}
provisioner "file" {
source = "${path.root}/templates/"
destination = "/tmp/open-swe-templates/"
destination = "/tmp/open-swe-templates"
}
provisioner "shell" {
@ -127,7 +134,9 @@ build {
"CLOUDWATCH_AGENT_DEB_URL=${var.cloudwatch_agent_deb_url}",
"AWSCLI_ZIP_URL=${var.awscli_zip_url}",
]
execute_command = "chmod +x {{ .Path }}; sudo -E bash '{{ .Path }}'"
# {{ .Vars }} MUST be included or the environment_vars above never reach the
# script (provision.sh runs under `set -u` and fails on the first reference).
execute_command = "chmod +x {{ .Path }}; {{ .Vars }} sudo -E bash '{{ .Path }}'"
script = "${path.root}/scripts/provision.sh"
}
}

View file

@ -21,11 +21,11 @@ infra/
│ ├── instance-role.ts # open-swe-<env>-instance-role (least-privilege)
│ ├── config-store.ts # Secrets Manager + SSM Parameter Store shells (T11)
│ ├── app-service.ts # EC2 box + imported-ALB ingress + Route53 + logs (T12)
│ └── ami-cache.ts # cached ARM64 AL2023 helper + EBS/AMI discipline docs
│ └── ami-cache.ts # baked open-swe AMI pin (by id) + EBS/replacement docs
├── test/
│ └── kebab-naming-aspect.test.ts # jest: Aspect passes conforming names, flags bad ones
├── cdk.json
├── cdk.context.json # COMMITTED — pins the AMI (see AMI cache discipline)
├── cdk.context.json # COMMITTED — {} (AMI is a static id pin; no lookups)
├── package.json # aws-cdk-lib pinned EXACT (2.260.0)
├── tsconfig.json
├── jest.config.js
@ -197,17 +197,21 @@ hooks hostname is scoped to `/webhooks/*` only (OSWE-T12-02 hygiene). An
X-Forwarded-For spoof candidate was **killed** — no code trusts the leftmost XFF.
No confirmed critical/high; no block.
## AMI cache discipline (EBS-fix plumbing — consumed by T12 `AppService`)
## Baked AMI + EBS-replacement discipline
`cachedArm64AmazonLinux2023()` (in `lib/constructs/ami-cache.ts`) returns an
ARM64 Amazon Linux 2023 image with `cachedInContext: true`, so the resolved AMI
id is pinned in the committed `cdk.context.json`. Without the pin, every deploy
could pick up a newer AL2023 release → AMI change → **EC2 instance replacement**
(the file-share data-loss root cause — memory `feedback_inline_ebs_volumes`).
`bakedOpenSweArm64()` (in `lib/constructs/ami-cache.ts`) pins the custom
**open-swe-base-arm64** image by EXACT id (`BAKED_OPEN_SWE_AMI_ID`) via
`MachineImage.genericLinux({ "us-east-1": "<ami-id>" })` — no SSM lookup, so synth
and deploy are fully offline/deterministic. The image is built by
`deploy/ami/open-swe-base.pkr.hcl` (ARM64 Ubuntu 24.04 + uv/py3.12 + nginx + CW
agent + boot templates, **no secrets**); the box's `user-data.sh` assumes that
baked layout (`/opt/open-swe`, `openswe` user, nginx, CW agent).
Design intent (now consumed by `AppService`):
Pinning by exact id (vs a `most_recent` name filter) is what prevents a routine
deploy from silently swapping the AMI → **EC2 instance replacement** (the
file-share data-loss root cause — memory `feedback_inline_ebs_volumes`).
- `userDataCausesReplacement: true` is the **deliberate** choice — user-data is
- `userDataCausesReplacement: true` is **deliberate** — user-data is
provisioning-only and carries no durable state.
- **No durable state on the box → no RETAIN volume.** The in-memory langgraph
store is rebuilt on every boot from S3 + Secrets Manager / SSM, so there is
@ -217,18 +221,16 @@ Design intent (now consumed by `AppService`):
deploy snapshot the root volume and wait `state=completed`, and re-verify "no
local-only durable state" first.
Refresh the AMI pin deliberately:
Refresh the AMI deliberately:
```bash
cdk context --reset 'ssm:account=328440206208:parameterName=/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64:region=us-east-1'
cdk synth # review the diff — it WILL show "requires replacement"
cd deploy/ami && packer build open-swe-base.pkr.hcl # prints the new ami-… id
# update BAKED_OPEN_SWE_AMI_ID in infra/lib/constructs/ami-cache.ts
cd infra && npx cdk diff OpenSweDevStack # WILL show "requires replacement"
```
> The committed `cdk.context.json` ships a dummy-but-valid-shaped AMI id
> (`ami-00000000000000000`) so `cdk synth` resolves the cache locally without any
> live AWS call. **Before the first real deploy**, repoint `AppService` to the
> baked `open-swe-base-arm64` AMI and pin its real id — the placeholder is
> intentionally un-bootable on stock AL2023.
> `cdk.context.json` is `{}` — nothing is resolved via context anymore (the AMI is
> a static id pin), so synth makes no live AWS call.
## Commands

View file

@ -1,3 +1 @@
{
"ssm:account=328440206208:parameterName=/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64:region=us-east-1": "ami-00000000000000000"
}
{}

View file

@ -1,62 +1,35 @@
import * as ec2 from "aws-cdk-lib/aws-ec2";
import { REGION } from "../config";
/**
* cdk.context.json key for the cached AL2023 ARM64 AMI. `latestAmazonLinux2023`
* + ARM_64 in the pinned aws-cdk-lib resolves the public SSM parameter below
* (the default-kernel alias for this CDK version is the `kernel-6.1` line); with
* `cachedInContext: true` CDK stores the resolved id under this exact key.
* Exported so the env stack can check "is the AMI already pinned?" and skip the
* resolve at synth when it is not — guaranteeing T3 synth makes no live AWS call.
* The baked open-swe base AMI (ARM64 Ubuntu 24.04 + uv/py3.12 + nginx + CW agent
* + boot templates — NO secrets), produced by `deploy/ami/open-swe-base.pkr.hcl`.
* Pinned by EXACT id (not a name filter) so synth/deploy is fully offline and
* deterministic.
*
* If an aws-cdk-lib bump changes the default kernel alias, `cdk synth` will write
* a new key into cdk.context.json — update this constant + the committed pin to
* match (the AMI cache naturally tracks the CDK version).
* Built 2026-06-26 from open-swe-base-arm64-20260626-201929.
*
* ── EBS / AMI replacement discipline (memory feedback_inline_ebs_volumes) ──
*
* Refresh DELIBERATELY: `cd deploy/ami && packer build open-swe-base.pkr.hcl`,
* then update this id. A new id → EC2 instance REPLACEMENT. Pinning by exact id
* (vs a `most_recent` name filter) is what prevents a routine deploy from silently
* swapping the AMI — the root cause of the file-share data-loss incidents
* (5/15, 5/27, 6/5).
*
* `userDataCausesReplacement: true` (AppService) is likewise DELIBERATE: user-data
* is provisioning-only and the box holds NO durable state (the langgraph store is
* in-memory, rebuilt every boot from S3 + Secrets Manager / SSM), so there is
* intentionally no standalone `ec2.Volume` + `removalPolicy.RETAIN`. The design
* goal is replacement-TOLERANCE, not avoidance.
*
* Operational guard before ANY replacing deploy (AMI / userData / instance-type):
* snapshot the root volume AND wait `state=completed`, re-verify "no local-only
* durable state", and review the `cdk diff` replacement at PR time.
*/
export const AL2023_ARM64_SSM_CONTEXT_KEY =
"ssm:account=328440206208:parameterName=/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64:region=us-east-1";
export const BAKED_OPEN_SWE_AMI_ID = "ami-0545363bb147229ff";
/**
* Cached ARM64 Amazon Linux 2023 machine image.
*
* ── EBS / AMI cache discipline (see memory feedback_inline_ebs_volumes) ──
*
* `cachedInContext: true` PINS the resolved AMI id into the committed
* cdk.context.json. Without it, `latestAmazonLinux2023()` resolves the NEWEST
* AL2023 release on every synth/deploy, so a routine deploy can swap the AMI →
* EC2 instance REPLACEMENT whenever AWS ships a release. That was the root cause
* of the file-share data-loss incidents (5/15, 5/27, 6/5). Refresh the pin
* DELIBERATELY:
*
* cdk context --reset '<AL2023_ARM64_SSM_CONTEXT_KEY>' && cdk synth
*
* then review the `cdk diff` (it WILL report "requires replacement") before
* deploying.
*
* ── userDataCausesReplacement intent (consumed at T12) ──
*
* open-swe user-data is provisioning-only: install the langgraph runtime, pull
* config from Secrets Manager / SSM, pull the build artifact from S3, start the
* service. It holds NO durable state. T12 sets `userDataCausesReplacement: true`
* DELIBERATELY so a config/bootstrap change rolls a fresh, known-good box.
*
* ── "No durable state on box → no RETAIN volume" assertion ──
*
* The langgraph store is in-memory and is reconstructed on every boot from S3
* (artifact) + Secrets Manager / SSM (config). Nothing of record lives only on
* the instance's disk. Therefore there is intentionally NO standalone
* `ec2.Volume` + `removalPolicy.RETAIN` here: the design goal is replacement-
* TOLERANCE, not replacement-avoidance.
*
* ── Operational guard still applies at T12 (feedback_inline_ebs_volumes) ──
*
* Before ANY replacing deploy (AMI / userData / instance-type change): snapshot
* the root volume AND wait for `state=completed`, re-verify "no local-only
* durable state" first, and ensure cdk-diff-on-PR surfaces the replacement at
* review time.
*/
export function cachedArm64AmazonLinux2023(): ec2.IMachineImage {
return ec2.MachineImage.latestAmazonLinux2023({
cpuType: ec2.AmazonLinuxCpuType.ARM_64,
cachedInContext: true,
});
/** The baked open-swe base image, pinned by id (offline, deterministic). */
export function bakedOpenSweArm64(): ec2.IMachineImage {
return ec2.MachineImage.genericLinux({ [REGION]: BAKED_OPEN_SWE_AMI_ID });
}

View file

@ -9,7 +9,7 @@ import * as route53 from "aws-cdk-lib/aws-route53";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
import { EnvName, prefix } from "../config";
import { cachedArm64AmazonLinux2023 } from "./ami-cache";
import { bakedOpenSweArm64 } from "./ami-cache";
/**
* Shared seahaven-vpc + internet-facing ALB facts (read-only recon 2026-06-26;
@ -177,13 +177,11 @@ export class AppService extends Construct {
],
},
instanceType: new ec2.InstanceType(net.instanceType),
// TODO(T12-deploy): repoint to the custom open-swe-base-arm64 AMI baked by
// packer (deploy/ami). The AL2023 ARM64 cache is a synth-time placeholder
// (pinned in cdk.context.json) so the box DEFINITION synths offline; the
// baked AMI id is pinned (cdk context) before the first real deploy.
// user-data.sh assumes the baked layout (/opt/open-swe, openswe user, nginx,
// CloudWatch agent) — it is NOT runnable on the stock AL2023 placeholder.
machineImage: cachedArm64AmazonLinux2023(),
// The baked open-swe base AMI (deploy/ami packer build) — ARM64 Ubuntu 24.04
// with the /opt/open-swe layout, openswe user, nginx, and CW agent that
// user-data.sh assumes. Pinned by exact id (see ami-cache.ts); refresh by
// rebuilding and updating BAKED_OPEN_SWE_AMI_ID.
machineImage: bakedOpenSweArm64(),
role: props.instanceRole,
securityGroup: instanceSg,
userData,

View file

@ -4,10 +4,7 @@ import { EnvName, prefix } from "./config";
import { AppService } from "./constructs/app-service";
import { ConfigStore } from "./constructs/config-store";
import { InstanceRole } from "./constructs/instance-role";
import {
AL2023_ARM64_SSM_CONTEXT_KEY,
cachedArm64AmazonLinux2023,
} from "./constructs/ami-cache";
import { BAKED_OPEN_SWE_AMI_ID } from "./constructs/ami-cache";
export interface OpenSweStackProps extends cdk.StackProps {
/** open-swe environment — drives the `open-swe-<env>-*` resource naming. */
@ -48,18 +45,12 @@ export class OpenSweStack extends cdk.Stack {
// The instance role already grants read on open-swe-<env>/* + /open-swe-<env>/*.
this.configStore = new ConfigStore(this, "Config", { envName });
// AMI cache discipline (see lib/constructs/ami-cache.ts). T3 is synth-only:
// resolve + surface the pinned AMI id ONLY when it is already cached in
// cdk.context.json, so synth never makes a live SSM call. T12 consumes
// `cachedArm64AmazonLinux2023()` for the actual ec2.Instance.
if (this.node.tryGetContext(AL2023_ARM64_SSM_CONTEXT_KEY) !== undefined) {
const amiId = cachedArm64AmazonLinux2023().getImage(this).imageId;
new cdk.CfnOutput(this, "PinnedAmiId", {
value: amiId,
description:
"Cached AL2023 ARM64 AMI id (pinned in cdk.context.json; consumed by the T12 EC2 instance).",
});
}
// Surface the baked open-swe base AMI id the box runs on (pinned by id in
// ami-cache.ts; refreshed by a deliberate packer rebuild → replacement).
new cdk.CfnOutput(this, "BakedAmiId", {
value: BAKED_OPEN_SWE_AMI_ID,
description: "Baked open-swe-base-arm64 AMI id consumed by the EC2 instance.",
});
// T12: compute + ingress. Imports the shared seahaven-vpc + ALB and adds the
// env's EC2 box, instance SG, target group, listener rules, DNS, log groups.