diff --git a/infra/packages/api/package-lock.json b/infra/packages/api/package-lock.json index 7fa85ff..e30d39e 100644 --- a/infra/packages/api/package-lock.json +++ b/infra/packages/api/package-lock.json @@ -14,6 +14,7 @@ "jose": "^6" }, "devDependencies": { + "@aws-sdk/client-ec2": "^3.1014.0", "@eslint/js": "^9.39.4", "@types/aws-lambda": "^8", "@types/node": "^25.5.0", @@ -274,6 +275,59 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-ec2": { + "version": "3.1014.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ec2/-/client-ec2-3.1014.0.tgz", + "integrity": "sha512-OeTGCF8a2q3oosVYRcLNGf17FAnMPm0qAiP89W/PGvCLZ+0lI/aiVCWPSWI16hpeIqXXp/9t5XwLFH1oAUw/dw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.23", + "@aws-sdk/credential-provider-node": "^3.972.24", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-ec2": "^3.972.17", + "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/region-config-resolver": "^3.972.9", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.10", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-ssm": { "version": "3.1012.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1012.0.tgz", @@ -649,6 +703,26 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-ec2": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-ec2/-/middleware-sdk-ec2-3.972.17.tgz", + "integrity": "sha512-8p8gSzSec0XeuqLnRU2ufTWTwV3TWocsV9I088ft0PMi+MvqYsy6oshD8e4ukDEWmAgKPyUuyJBcHMnQ8CcXcg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-format-url": "^3.972.8", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-user-agent": { "version": "3.972.24", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.24.tgz", diff --git a/infra/packages/api/package.json b/infra/packages/api/package.json index 18c5499..b407bcd 100644 --- a/infra/packages/api/package.json +++ b/infra/packages/api/package.json @@ -17,6 +17,7 @@ "jose": "^6" }, "devDependencies": { + "@aws-sdk/client-ec2": "^3.1014.0", "@eslint/js": "^9.39.4", "@types/aws-lambda": "^8", "@types/node": "^25.5.0", diff --git a/infra/packages/api/src/lib/ec2.ts b/infra/packages/api/src/lib/ec2.ts new file mode 100644 index 0000000..350a19b --- /dev/null +++ b/infra/packages/api/src/lib/ec2.ts @@ -0,0 +1,51 @@ +import { + EC2Client, + DescribeInstancesCommand, +} from "@aws-sdk/client-ec2"; + +const REGION = process.env.AWS_REGION ?? "us-west-2"; +const RUNNER_TAG_KEY = process.env.EC2_TAG_KEY ?? "Role"; +const RUNNER_TAG_VALUE = process.env.EC2_TAG_VALUE ?? "task-forge-runner"; + +const ec2 = new EC2Client({ region: REGION }); + +let cached: string | undefined; + +/** + * Resolve the task-runner EC2 instance ID by tag lookup. + * Falls back to EC2_INSTANCE_ID env var. Result is cached for the + * Lambda's lifetime so the EC2 API is called at most once per cold start. + */ +export async function resolveInstanceId(): Promise { + if (cached !== undefined) return cached; + + const envId = (process.env.EC2_INSTANCE_ID ?? "").trim(); + if (envId) { + cached = envId; + return cached; + } + + try { + const resp = await ec2.send( + new DescribeInstancesCommand({ + Filters: [ + { Name: `tag:${RUNNER_TAG_KEY}`, Values: [RUNNER_TAG_VALUE] }, + { Name: "instance-state-name", Values: ["running"] }, + ], + }), + ); + for (const r of resp.Reservations ?? []) { + for (const inst of r.Instances ?? []) { + if (inst.InstanceId) { + cached = inst.InstanceId; + return cached; + } + } + } + } catch (err) { + console.warn("ec2: failed to resolve instance by tag:", err); + } + + cached = ""; + return cached; +} diff --git a/infra/packages/api/src/lib/ssm.ts b/infra/packages/api/src/lib/ssm.ts index 2227890..9a01d8f 100644 --- a/infra/packages/api/src/lib/ssm.ts +++ b/infra/packages/api/src/lib/ssm.ts @@ -1,16 +1,11 @@ import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm"; -import { Resource } from "sst"; +import { resolveInstanceId } from "./ec2"; -const REGION = process.env.AWS_REGION ?? "us-west-2"; const WORK_DIR = process.env.EC2_WORK_DIR ?? "/home/ec2-user/workspace/task-forge"; const VENV_PYTHON = `${WORK_DIR}/.venv/bin/python3`; const RUN_TASK_SCRIPT = `${WORK_DIR}/run_task.py`; -const ssmClient = new SSMClient({ region: REGION }); - -function getInstanceId(): string { - return process.env.EC2_INSTANCE_ID || (Resource as any).Ec2InstanceId?.value || ""; -} +const ssmClient = new SSMClient({ region: process.env.AWS_REGION ?? "us-west-2" }); /** Thrown when runner cannot be dispatched (e.g. EC2 instance ID not configured). */ export class RunnerUnavailableError extends Error { @@ -21,10 +16,10 @@ export class RunnerUnavailableError extends Error { } async function sendCommand(command: string): Promise { - const instanceId = getInstanceId(); + const instanceId = await resolveInstanceId(); if (!instanceId) { throw new RunnerUnavailableError( - "EC2_INSTANCE_ID not set — runner dispatch unavailable" + "Could not resolve EC2 instance — check Role tag or EC2_INSTANCE_ID" ); } await ssmClient.send( diff --git a/infra/packages/autopilot/src/index.ts b/infra/packages/autopilot/src/index.ts index 1f1b532..abf5155 100644 --- a/infra/packages/autopilot/src/index.ts +++ b/infra/packages/autopilot/src/index.ts @@ -1,7 +1,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"; import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm"; -import { Resource } from "sst"; +import { resolveInstanceId } from "../../api/src/lib/ec2"; const TABLE = process.env.DYNAMO_TABLE ?? "agent-tasks"; const REGION = process.env.AWS_REGION ?? "us-west-2"; @@ -14,14 +14,6 @@ const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: REGION }), }); const ssm = new SSMClient({ region: REGION }); -function getInstanceId(): string { - return ( - process.env.EC2_INSTANCE_ID || - (Resource as { Ec2InstanceId?: { value: string } }).Ec2InstanceId?.value || - "" - ); -} - function shouldTriggerProposePlan(item: Record): boolean { if (item.autopilot !== true || !item.project_id) return false; const mode = @@ -60,9 +52,9 @@ async function listActiveAutopilotProjectIds(): Promise { } async function triggerProposePlan(projectId: string): Promise { - const instanceId = getInstanceId(); + const instanceId = await resolveInstanceId(); if (!instanceId) { - console.warn("No EC2_INSTANCE_ID — skipping autopilot plan trigger"); + console.warn("Could not resolve EC2 instance — skipping autopilot plan trigger"); return; } const esc = (s: string) => s.replace(/'/g, "'\\''"); diff --git a/infra/packages/metrics/src/index.ts b/infra/packages/metrics/src/index.ts index 37a346c..67b32d9 100644 --- a/infra/packages/metrics/src/index.ts +++ b/infra/packages/metrics/src/index.ts @@ -6,9 +6,9 @@ import { UpdateCommand, } from "@aws-sdk/lib-dynamodb"; import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm"; -import { Resource } from "sst"; import { fetchPageSpeedMetrics } from "./pagespeed.js"; import { fetchGitHubMetrics } from "./github.js"; +import { resolveInstanceId } from "../../api/src/lib/ec2"; const TABLE = process.env.DYNAMO_TABLE ?? "agent-tasks"; const REGION = process.env.AWS_REGION ?? "us-west-2"; @@ -114,18 +114,10 @@ async function updateKPICurrentValues( ); } -function getInstanceId(): string { - return ( - process.env.EC2_INSTANCE_ID || - (Resource as { Ec2InstanceId?: { value: string } }).Ec2InstanceId?.value || - "" - ); -} - async function triggerDailyCycle(projectId: string): Promise { - const instanceId = getInstanceId(); + const instanceId = await resolveInstanceId(); if (!instanceId) { - console.warn("No EC2_INSTANCE_ID — skipping daily cycle trigger"); + console.warn("Could not resolve EC2 instance — skipping daily cycle trigger"); return; } const esc = (s: string) => s.replace(/'/g, "'\\''"); diff --git a/infra/packages/repo-scanner/src/index.ts b/infra/packages/repo-scanner/src/index.ts index 9dc4fac..ef314d8 100644 --- a/infra/packages/repo-scanner/src/index.ts +++ b/infra/packages/repo-scanner/src/index.ts @@ -17,7 +17,7 @@ import { ScanCommand, } from "@aws-sdk/lib-dynamodb"; import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm"; -import { Resource } from "sst"; +import { resolveInstanceId } from "../../api/src/lib/ec2"; const REGION = process.env.AWS_REGION ?? "us-west-2"; const TABLE_NAME = process.env.DYNAMO_TABLE ?? "agent-tasks"; @@ -63,14 +63,10 @@ const VENV_PYTHON = `${WORK_DIR}/.venv/bin/python3`; const RUN_TASK_SCRIPT = `${WORK_DIR}/run_task.py`; const ssmClient = new SSMClient({ region: REGION }); -function getInstanceId(): string { - return process.env.EC2_INSTANCE_ID || (Resource as any).Ec2InstanceId?.value || ""; -} - async function triggerRunner(taskId: string): Promise { - const instanceId = getInstanceId(); + const instanceId = await resolveInstanceId(); if (!instanceId) { - console.warn("EC2_INSTANCE_ID not set — skipping runner trigger for %s", taskId); + console.warn("Could not resolve EC2 instance — skipping runner trigger for %s", taskId); return; } try { diff --git a/infra/sst-env.d.ts b/infra/sst-env.d.ts index c81c791..ff2473c 100644 --- a/infra/sst-env.d.ts +++ b/infra/sst-env.d.ts @@ -26,10 +26,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "Ec2InstanceId": { - "type": "sst.sst.Secret" - "value": string - } "GitHubToken": { "type": "sst.sst.Secret" "value": string diff --git a/infra/sst.config.ts b/infra/sst.config.ts index b67c123..f32401a 100644 --- a/infra/sst.config.ts +++ b/infra/sst.config.ts @@ -42,9 +42,13 @@ export default $config({ const authSecretKey = new sst.Secret("AuthSecretKey"); const authEmail = new sst.Secret("AuthEmail"); const authPassword = new sst.Secret("AuthPassword"); - const ec2InstanceId = new sst.Secret("Ec2InstanceId"); const gitHubToken = new sst.Secret("GitHubToken"); + const ec2LookupPermission = { + actions: ["ec2:DescribeInstances"], + resources: ["*"], + }; + // -- DynamoDB -- const table = new sst.aws.Dynamo("Tasks", { fields: { @@ -136,12 +140,13 @@ export default $config({ runtime: "nodejs22.x", timeout: "120 seconds", memory: "256 MB", - link: [table, gitHubToken, ec2InstanceId], + link: [table, gitHubToken], permissions: [ { actions: ["ssm:SendCommand"], resources: ["*"], }, + ec2LookupPermission, ], environment: { DYNAMO_TABLE: tablePhysicalName, @@ -158,12 +163,13 @@ export default $config({ runtime: "nodejs22.x", timeout: "120 seconds", memory: "256 MB", - link: [table, ec2InstanceId], + link: [table], permissions: [ { actions: ["ssm:SendCommand"], resources: ["*"], }, + ec2LookupPermission, ], environment: { DYNAMO_TABLE: tablePhysicalName, @@ -179,7 +185,7 @@ export default $config({ runtime: "nodejs22.x", timeout: "60 seconds", memory: "128 MB", - link: [table, gitHubToken, ec2InstanceId], + link: [table, gitHubToken], environment: { DYNAMO_TABLE: tablePhysicalName, GITHUB_OWNER: githubOwner, @@ -187,6 +193,13 @@ export default $config({ ISSUE_LABEL: "agent", SCAN_CI: "true", }, + permissions: [ + { + actions: ["ssm:SendCommand"], + resources: ["*"], + }, + ec2LookupPermission, + ], }, }); @@ -210,7 +223,7 @@ export default $config({ runtime: "nodejs22.x", timeout: "30 seconds", memory: "256 MB", - link: [table, authSecretKey, authEmail, authPassword, ec2InstanceId], + link: [table, authSecretKey, authEmail, authPassword], permissions: [ { actions: ["ssm:SendCommand"], @@ -220,6 +233,7 @@ export default $config({ actions: ["bedrock:InvokeModel"], resources: ["*"], }, + ec2LookupPermission, ], environment: { DYNAMO_TABLE: tablePhysicalName,