fix(deploy): use %%...%% for CDK user-data tokens (don't collide with @@ sed) (#21)

The systemd unit booted with a literal `@@OPENSWE_ENV@@` (fetch-config.sh got the
token, not "dev" -> exit 2 -> crash-loop) because user-data.sh is double-templated:
CDK substitutes @@tokens@@ AND user-data seds @@tokens@@ into the baked
systemd/nginx files. CDK's `.replace(/@@OPENSWE_ENV@@/g, "dev")` clobbered the sed
PATTERN (`s|@@OPENSWE_ENV@@|...|` -> `s|dev|...|`, a no-op), so the unit's token
never got replaced. Same collision hit @@SERVER_NAME@@ (masked by nginx
default_server).

Fix: CDK tokens move to a DISTINCT delimiter %%...%% (rendered in app-service.ts);
the @@...@@ tokens stay for the baked-template seds. No AMI rebuild (templates
unchanged). Add a guard test asserting no unresolved %%CDK%% token survives in the
synthesized user-data. Also add .github/scripts/** to build-artifacts paths so
script-only changes trigger a publish.

jest 20/20; tsc + shellcheck clean; rendered user-data: OPENSWE_ENV="dev",
SERVER_NAME="openswe-dev.seahaven.com", @@ sed patterns preserved, 16872 B.
This commit is contained in:
Adam Moussa 2026-06-26 19:06:37 -04:00 • committed by GitHub
parent 9e2b215f08
commit 5fa132205b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 33 additions and 11 deletions

View file

@ -40,6 +40,7 @@ on:
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/build-artifacts.yml"
- ".github/scripts/**"
workflow_dispatch:
concurrency:

View file

@ -22,10 +22,14 @@ exec > >(tee -a /var/log/open-swe-user-data.log) 2>&1
echo "==> open-swe user-data start $(date -u +%FT%TZ)"
# --- CDK-rendered values -----------------------------------------------------
OPENSWE_ENV="@@OPENSWE_ENV@@" # dev | prod
ASSETS_BUCKET="@@ASSETS_BUCKET@@" # open-swe-<env>-assets
SERVER_NAME="@@SERVER_NAME@@" # openswe[-dev].seahaven.com
ARTIFACT_PREFIX="@@ARTIFACT_PREFIX@@" # e.g. releases/latest
# NOTE: CDK-substituted tokens use %%...%% (rendered by app-service.ts), DISTINCT
# from the @@...@@ tokens this script seds into the baked systemd/nginx templates.
# The two MUST NOT share a delimiter: a shared @@OPENSWE_ENV@@ / @@SERVER_NAME@@
# let CDK clobber the sed PATTERN, leaving the unit's token unsubstituted.
OPENSWE_ENV="%%OPENSWE_ENV%%" # dev | prod
ASSETS_BUCKET="%%ASSETS_BUCKET%%" # open-swe-<env>-assets
SERVER_NAME="%%SERVER_NAME%%" # openswe[-dev].seahaven.com
ARTIFACT_PREFIX="%%ARTIFACT_PREFIX%%" # e.g. releases/latest
# --- fixed layout (must match provision.sh + templates) ----------------------
SERVICE_USER="openswe"
@ -69,13 +73,13 @@ mountpoint -q /run/open-swe || mount /run/open-swe || mount -t tmpfs \
# --- install the deploy script (single source of the app-deploy procedure) ---
# deploy.sh (deploy/ami/deploy.sh) pulls the release from S3, builds the venv with
# `uv sync`, and restarts the service. CDK base64-renders the file into the
# @@DEPLOY_SH_B64@@ token below so it is a normal reviewable repo file, not an
# %%DEPLOY_SH_B64%% token below so it is a normal reviewable repo file, not an
# inline heredoc. The `open-swe-<env>-deploy` SSM document runs this same script
# for every subsequent release.
echo "==> install /opt/open-swe/bin/deploy.sh"
install -d -o root -g root -m 0755 /opt/open-swe/bin
base64 -d >/opt/open-swe/bin/deploy.sh <<'DEPLOY_SH_B64'
@@DEPLOY_SH_B64@@
%%DEPLOY_SH_B64%%
DEPLOY_SH_B64
chmod 0755 /opt/open-swe/bin/deploy.sh

View file

@ -183,11 +183,15 @@ export class AppService extends Construct {
const userData = ec2.UserData.custom(
fs
.readFileSync(userDataPath, "utf8")
.replace(/@@OPENSWE_ENV@@/g, env)
.replace(/@@ASSETS_BUCKET@@/g, `${p}-assets`)
.replace(/@@SERVER_NAME@@/g, net.dashboardHost)
.replace(/@@ARTIFACT_PREFIX@@/g, artifactPrefix)
.replace(/@@DEPLOY_SH_B64@@/g, deployShB64),
// %%...%% tokens are CDK-substituted here; they are DELIBERATELY a
// different delimiter from the @@...@@ tokens user-data.sh seds into the
// baked systemd/nginx templates, so CDK can never clobber a sed pattern
// (a shared @@OPENSWE_ENV@@/@@SERVER_NAME@@ left the unit unsubstituted).
.replace(/%%OPENSWE_ENV%%/g, env)
.replace(/%%ASSETS_BUCKET%%/g, `${p}-assets`)
.replace(/%%SERVER_NAME%%/g, net.dashboardHost)
.replace(/%%ARTIFACT_PREFIX%%/g, artifactPrefix)
.replace(/%%DEPLOY_SH_B64%%/g, deployShB64),
);
this.instance = new ec2.Instance(this, "Instance", {

View file

@ -71,4 +71,17 @@ describe("ASCII-only EC2 description fields", () => {
expect(`${id}: ${encoded} bytes`).toBe(encoded < 25600 ? `${id}: ${encoded} bytes` : "OVER 25600");
}
});
// CDK substitutes %%...%% tokens in user-data at synth. Any %%TOKEN%% left in the
// rendered script means a token wasn't wired in app-service.ts (the @@...@@ tokens
// are intentional — user-data seds those into the baked templates at boot).
it("user-data has no unresolved %%CDK%% tokens", () => {
for (const [id, r] of Object.entries(resources)) {
if (r.Type !== "AWS::EC2::Instance") continue;
const ud = (r.Properties?.UserData as { "Fn::Base64"?: string }) ?? {};
const script = typeof ud["Fn::Base64"] === "string" ? ud["Fn::Base64"] : "";
const leftover = script.match(/%%[A-Z0-9_]+%%/g) ?? [];
expect(`${id}: ${leftover.join(",")}`).toBe(`${id}: `);
}
});
});