From 5db4bc5f6963b422c689482f616857d18dea2ded Mon Sep 17 00:00:00 2001 From: Jordan Kail <13952435+jckail@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:08:50 -0700 Subject: [PATCH] Accept ECS empty entry-point normalization for migration tasks --- scripts/deployment/rollout.mjs | 4 +++- scripts/deployment/rollout.test.mjs | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/deployment/rollout.mjs b/scripts/deployment/rollout.mjs index 665ba41..16cedc6 100644 --- a/scripts/deployment/rollout.mjs +++ b/scripts/deployment/rollout.mjs @@ -91,7 +91,9 @@ export function migrationRegistration(definition, image, identity, expectedHash, check(/^TemplateApp[a-zA-Z0-9_-]{0,244}$/.test(definition.family ?? '') && definition.cpu === '256' && definition.memory === '512', 'Unsupported migration task sizing or family'); for (const key of ['executionRoleArn', 'taskRoleArn']) check(typeof definition[key] === 'string' && definition[key].startsWith(`arn:aws:iam::${account}:role/TemplateApp-`), 'Migration roles must be existing account roles'); const container = definition.containerDefinitions[0]; - check(container.name === 'Migrate' && container.essential !== false && container.command?.length === 1 && container.command[0] === 'migrate' && !container.entryPoint && !(container.mountPoints?.length) && !(container.portMappings?.length), 'Unsupported migration container'); + // ECS DescribeTaskDefinition normalizes an omitted entry point to an empty array. + const imageEntryPoint = container.entryPoint === undefined || (Array.isArray(container.entryPoint) && container.entryPoint.length === 0); + check(container.name === 'Migrate' && container.essential !== false && container.command?.length === 1 && container.command[0] === 'migrate' && imageEntryPoint && !(container.mountPoints?.length) && !(container.portMappings?.length), 'Unsupported migration container'); check(container.logConfiguration?.logDriver === 'awslogs' && container.logConfiguration.options?.['awslogs-region'] === region, 'Migration logging is invalid'); const externalUrl = (container.secrets ?? []).filter(secret => secret.name === 'DATABASE_URL'); let secrets, secretArns; diff --git a/scripts/deployment/rollout.test.mjs b/scripts/deployment/rollout.test.mjs index 12adcee..7004509 100644 --- a/scripts/deployment/rollout.test.mjs +++ b/scripts/deployment/rollout.test.mjs @@ -8,7 +8,7 @@ const account = '123456789012', region = 'us-east-1', assetHash = 'a'.repeat(64) const ecsArn = resource => `arn:aws:ecs:${region}:${account}:${resource}`; const role = name => `arn:aws:iam::${account}:role/TemplateApp-${name}`; const dbSecret = `arn:aws:secretsmanager:${region}:${account}:secret:TemplateApp-db-AbCdEf`; -const definition = { taskDefinitionArn: ecsArn('task-definition/TemplateAppMigration:1'), family: 'TemplateAppMigration', executionRoleArn: role('Execution'), taskRoleArn: role('Task'), cpu: '256', memory: '512', networkMode: 'awsvpc', requiresCompatibilities: ['FARGATE'], containerDefinitions: [{ name: 'Migrate', essential: true, image: 'old', command: ['migrate'], logConfiguration: { logDriver: 'awslogs', options: { 'awslogs-region': region, 'awslogs-group': '/migration', 'awslogs-stream-prefix': 'migrate' } }, secrets: Object.entries({ DB_HOST: 'host', DB_PORT: 'port', DB_USER: 'username', DB_PASSWORD: 'password', DB_NAME: 'dbname' }).map(([name, key]) => ({ name, valueFrom: `${dbSecret}:${key}::` })).concat({ name: 'CLERK_SECRET_KEY', valueFrom: 'unrelated' }), environment: [{ name: 'POINTUP_DEV_TOKEN', value: 'must-not-survive' }] }] }; +const definition = { taskDefinitionArn: ecsArn('task-definition/TemplateAppMigration:1'), family: 'TemplateAppMigration', executionRoleArn: role('Execution'), taskRoleArn: role('Task'), cpu: '256', memory: '512', networkMode: 'awsvpc', requiresCompatibilities: ['FARGATE'], containerDefinitions: [{ name: 'Migrate', essential: true, image: 'old', command: ['migrate'], entryPoint: [], mountPoints: [], portMappings: [], logConfiguration: { logDriver: 'awslogs', options: { 'awslogs-region': region, 'awslogs-group': '/migration', 'awslogs-stream-prefix': 'migrate' } }, secrets: Object.entries({ DB_HOST: 'host', DB_PORT: 'port', DB_USER: 'username', DB_PASSWORD: 'password', DB_NAME: 'dbname' }).map(([name, key]) => ({ name, valueFrom: `${dbSecret}:${key}::` })).concat({ name: 'CLERK_SECRET_KEY', valueFrom: 'unrelated' }), environment: [{ name: 'POINTUP_DEV_TOKEN', value: 'must-not-survive' }] }] }; const env = { AWS_REGION: region, WEB_CERTIFICATE_ARN: `arn:aws:acm:${region}:${account}:certificate/${'e'.repeat(8)}-${'e'.repeat(4)}-${'e'.repeat(4)}-${'e'.repeat(4)}-${'e'.repeat(12)}`, WEB_DOMAIN_NAME: 'pointup.test', CLERK_PUBLISHABLE_KEY: 'pk_live_synthetic_offline_fixture', GITHUB_SHA: 'f'.repeat(40), APPROVED_DATABASE_SNAPSHOT_ARN: `arn:aws:rds:${region}:${account}:snapshot:approved-release` }; function harness(options = {}) { const files = new Map(), calls = []; let latestRegistration; @@ -344,3 +344,20 @@ test('hosted callback cannot substitute metadata for failed real Docker reconstr await assert.rejects(rollout(fixture.args),/Stopped isolated candidate reconstruction/); assert.ok(!fixture.calls.some(call=>call.args[0]==='ecs' && call.args[1]==='register-task-definition')); }); + +test('AWS empty entry-point normalization retains the image entry point in candidate registration', () => { + for (const entryPoint of [undefined, []]) { + const actualShape = structuredClone(definition); + actualShape.containerDefinitions[0].entryPoint = entryPoint; + const prepared = migrationRegistration(actualShape, 'candidate', {account,region}, expectedHash); + assert.equal(prepared.registration.containerDefinitions[0].entryPoint, undefined); + assert.deepEqual(prepared.registration.containerDefinitions[0].command, ['migrate']); + } +}); +test('migration registration still refuses entry-point overrides and invalid API shapes', () => { + for (const entryPoint of [['sh', '-c'], ['node', 'other.js'], null, 'node', {}]) { + const actualShape = structuredClone(definition); + actualShape.containerDefinitions[0].entryPoint = entryPoint; + assert.throws(() => migrationRegistration(actualShape, 'candidate', {account,region}, expectedHash), /Unsupported migration container/); + } +});