diff --git a/src/components/ecs-service/README.md b/src/components/ecs-service/README.md index d8127730..e1fedfab 100644 --- a/src/components/ecs-service/README.md +++ b/src/components/ecs-service/README.md @@ -84,9 +84,7 @@ const service = new studion.EcsService('api', { }); export const discoveryArn = service.serviceDiscoveryService?.arn; -export const taskDefinitionArn = service.taskDefinition.apply( - taskDefinition => taskDefinition.arn, -); +export const taskDefinitionArn = service.taskDefinition.arn; export const logGroupName = service.logGroup.name; export const persistentFileSystemId = ecsService.persistentStorage.apply( storage => storage?.fileSystem.id, @@ -175,7 +173,7 @@ Direct constructor input: `args: EcsService.Args` | `name`
`string` | Component name. | | `vpc`
`pulumi.Output` | VPC captured from the constructor arguments. | | `logGroup`
`aws.cloudwatch.LogGroup` | CloudWatch log group for task logs. | -| `taskDefinition`
`pulumi.Output` | Generated ECS task definition. | +| `taskDefinition`
`aws.ecs.TaskDefinition` | Generated ECS task definition. | | `taskExecutionRole`
`aws.iam.Role` | Generated execution role. | | `taskRole`
`aws.iam.Role` | Generated task role. | | `service`
`aws.ecs.Service` | ECS service resource. | diff --git a/src/components/ecs-service/index.test.ts b/src/components/ecs-service/index.test.ts new file mode 100644 index 00000000..71275c58 --- /dev/null +++ b/src/components/ecs-service/index.test.ts @@ -0,0 +1,320 @@ +import { before, beforeEach, describe, it } from 'node:test'; +import * as assert from 'node:assert'; +import type * as aws from '@pulumi/aws'; +import type * as awsx from '@pulumi/awsx'; +import * as pulumi from '@pulumi/pulumi'; + +const TASK_DEFINITION_TYPE = 'aws:ecs/taskDefinition:TaskDefinition'; +const DEFAULT_REGION = 'us-west-2'; + +type EcsServiceClass = typeof import('./index').EcsService; +let EcsService: EcsServiceClass; + +describe('EcsService', () => { + before(async () => { + await pulumi.runtime.setMocks( + { + call: args => { + if (args.token === 'aws:index/getRegion:getRegion') { + return { id: 'us-east-1', name: 'us-east-1' }; + } + + return args.inputs; + }, + newResource: args => { + captureResource(args.type, args.name, args.inputs); + + const id = `${args.name}-id`; + const name = args.inputs.name ?? args.name; + + return { + id: args.custom ? id : undefined, + state: { + ...args.inputs, + arn: `arn:aws:mock:${args.type}:${args.name}`, + arnWithoutRevision: `arn:aws:mock:${args.type}:${args.name}`, + id, + name, + }, + }; + }, + }, + 'icb-test-ecs-service', + 'dev', + false, + ); + + ({ EcsService } = await import('./index')); + }); + + beforeEach(() => { + capturedResources.clear(); + }); + + describe('task definition serialization', () => { + describe('nested Pulumi output values', () => { + let serializedContainerDefinitions: unknown; + + beforeEach(async () => { + await pulumi.runtime.runInPulumiStack(async () => { + const service = createEcsService('serialization', { + containers: [ + createDefaultContainer({ + name: pulumi.output('app'), + image: pulumi.output('example/image:latest'), + command: pulumi.output(['sh', '-c', 'echo ok']), + environment: pulumi.output([ + { name: 'FROM_OUTPUT', value: 'resolved' }, + ]), + portMappings: pulumi.output([ + EcsService.createTcpPortMapping(pulumi.output(8080)), + ]), + mountPoints: [ + { + sourceVolume: pulumi.output('data-volume'), + containerPath: pulumi.output('/data'), + readOnly: pulumi.output(true), + }, + ], + }), + ], + }); + + await resolveInput(service.taskDefinition.arn); + }); + + const { containerDefinitions } = + getCapturedResourceInput(TASK_DEFINITION_TYPE); + + serializedContainerDefinitions = containerDefinitions + ? await resolveInput(containerDefinitions) + : undefined; + }); + + it('should pass valid serialized container definitions to the task definition', () => { + assert.ok( + typeof serializedContainerDefinitions === 'string', + 'Container definitions should resolve to a JSON string', + ); + + assert.doesNotMatch( + serializedContainerDefinitions, + /__pulumi|OutputImpl|Calling \[toJSON\]/, + 'Serialized container definitions should not contain Pulumi output internals', + ); + }); + + it('should serialize an array of containers', () => { + const containerDefinitions = JSON.parse( + serializedContainerDefinitions as string, + ); + + assert.ok( + Array.isArray(containerDefinitions), + 'Serialized container definitions should parse to an array', + ); + assert.strictEqual( + containerDefinitions.length, + 1, + 'Should serialize one container definition', + ); + assert.ok( + containerDefinitions[0], + 'Serialized container definition should not be falsy.', + ); + }); + + it('should serialize basic container fields', () => { + const [container] = JSON.parse( + serializedContainerDefinitions as string, + ); + + assert.strictEqual(container.name, 'app'); + assert.strictEqual(container.image, 'example/image:latest'); + }); + + it('should serialize command and environment fields', () => { + const [container] = JSON.parse( + serializedContainerDefinitions as string, + ); + + assert.deepStrictEqual(container.command, ['sh', '-c', 'echo ok']); + assert.deepStrictEqual(container.environment, [ + { name: 'FROM_OUTPUT', value: 'resolved' }, + ]); + }); + + it('should serialize port mappings and mount points', () => { + const [container] = JSON.parse( + serializedContainerDefinitions as string, + ); + + assert.deepStrictEqual(container.portMappings, [ + { containerPort: 8080, hostPort: 8080, protocol: 'tcp' }, + ]); + assert.deepStrictEqual(container.mountPoints, [ + { + sourceVolume: 'data-volume', + containerPath: '/data', + readOnly: true, + }, + ]); + }); + + it('should preserve generated container defaults', () => { + const [container] = JSON.parse( + serializedContainerDefinitions as string, + ); + + assert.strictEqual(container.readonlyRootFilesystem, false); + assert.deepStrictEqual(container.logConfiguration, { + logDriver: 'awslogs', + options: { + 'awslogs-group': 'serialization-log-group', + 'awslogs-region': DEFAULT_REGION, + 'awslogs-stream-prefix': 'ecs', + }, + }); + }); + }); + + it('should register the task definition when container definitions contain unknown preview values', async () => { + await pulumi.runtime.runInPulumiStack(async () => { + createEcsService('preview-serialization', { + containers: [ + createDefaultContainer({ + image: pulumi.output(pulumi.unknown) as pulumi.Output, + command: pulumi.output(['sh', '-c', 'echo ok']), + }), + ], + }); + + await waitForCapturedResource(TASK_DEFINITION_TYPE); + }); + + const taskDefinitionInputs = + getCapturedResourceInput(TASK_DEFINITION_TYPE); + + assert.ok( + 'containerDefinitions' in taskDefinitionInputs, + 'Task definition should receive a containerDefinitions input', + ); + assert.strictEqual( + taskDefinitionInputs.containerDefinitions, + undefined, + 'Unresolved container definitions should remain an unknown provider input instead of blocking resource registration', + ); + }); + }); +}); + +type EcsServiceInstance = InstanceType; +type EcsServiceArgs = ConstructorParameters[1]; +type EcsServiceContainer = EcsServiceArgs['containers'][number]; +type ResourceInputs = Record; +type CapturedResource = { + name: string; + inputs: ResourceInputs; +}; +type TaskDefinitionInputs = ResourceInputs & { + containerDefinitions?: pulumi.Input; +}; + +const capturedResources = new Map(); + +function createEcsService( + name: string, + args: Partial = {}, +): EcsServiceInstance { + const cluster = { + id: pulumi.output('cluster-arn'), + name: pulumi.output('cluster-name'), + } as unknown as aws.ecs.Cluster; + const vpc = { + vpcId: pulumi.output('vpc-123456'), + privateSubnetIds: pulumi.output(['subnet-private-1']), + publicSubnetIds: pulumi.output(['subnet-public-1']), + vpc: pulumi.output({ cidrBlock: '10.0.0.0/16' }), + } as unknown as awsx.ec2.Vpc; + + return new EcsService(name, { + cluster, + vpc, + region: DEFAULT_REGION, + containers: [createDefaultContainer()], + ...args, + }); +} + +function createDefaultContainer( + overrides: Partial = {}, +): EcsServiceContainer { + return { + name: 'app', + image: 'example/image:latest', + ...overrides, + }; +} + +// TODO: Pulumi mocks; move into separate file to allow reuse across unit test files + +function captureResource( + type: string, + name: string, + inputs: ResourceInputs, +): void { + const resourcesForType = capturedResources.get(type) ?? []; + + resourcesForType.push({ name, inputs }); + capturedResources.set(type, resourcesForType); +} + +function getCapturedResources(type: string): CapturedResource[] { + return capturedResources.get(type) ?? []; +} + +function getCapturedResourceInput(type: string): T { + const inputs = getCapturedResources(type).map(resource => resource.inputs); + + assert.strictEqual( + inputs.length, + 1, + `Expected exactly one registered resource for ${type}`, + ); + + return inputs[0] as T; +} + +async function waitForCapturedResource( + type: string, + options: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const { timeoutMs = 1000, intervalMs = 10 } = options; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (getCapturedResources(type).length > 0) { + return; + } + + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + + assert.fail( + `Timed out after ${timeoutMs}ms waiting for resource registration: ${type}`, + ); +} + +async function resolveInput(value: pulumi.Input): Promise { + if (pulumi.Output.isInstance(value)) { + return new Promise(resolve => { + value.apply(resolved => { + resolve(resolved); + + return resolved; + }); + }); + } + + return value; +} diff --git a/src/components/ecs-service/index.ts b/src/components/ecs-service/index.ts index 5d4ebc7f..0538e24f 100644 --- a/src/components/ecs-service/index.ts +++ b/src/components/ecs-service/index.ts @@ -203,7 +203,7 @@ export class EcsService extends pulumi.ComponentResource { name: string; vpc: pulumi.Output; logGroup: aws.cloudwatch.LogGroup; - taskDefinition: pulumi.Output; + taskDefinition: aws.ecs.TaskDefinition; taskExecutionRole: aws.iam.Role; taskRole: aws.iam.Role; service: aws.ecs.Service; @@ -310,35 +310,30 @@ export class EcsService extends pulumi.ComponentResource { size: pulumi.Input, tags: pulumi.Input, region: pulumi.Input, - ): pulumi.Output { + ): aws.ecs.TaskDefinition { const stack = pulumi.getStack(); const { cpu, memory } = pulumi.output(size).apply(parseTaskSize); const containerDefinitions = containers.map(container => { return this.createContainerDefinition(container, region); }); - const taskDefinitionVolumes = this.createTaskDefinitionVolumes(volumes); - return pulumi.all(containerDefinitions).apply(containerDefinitions => { - return taskDefinitionVolumes.apply(volumes => { - return new aws.ecs.TaskDefinition( - `${this.name}-task-definition`, - { - family: family ?? `${this.name}-task-definition-${stack}`, - networkMode: 'awsvpc', - executionRoleArn: taskExecutionRole.arn, - taskRoleArn: taskRole.arn, - cpu, - memory, - requiresCompatibilities: ['FARGATE'], - containerDefinitions: JSON.stringify(containerDefinitions), - ...(volumes?.length ? { volumes } : {}), - tags: { ...commonTags, ...tags }, - }, - { parent: this }, - ); - }); - }); + return new aws.ecs.TaskDefinition( + `${this.name}-task-definition`, + { + family: family ?? `${this.name}-task-definition-${stack}`, + networkMode: 'awsvpc', + executionRoleArn: taskExecutionRole.arn, + taskRoleArn: taskRole.arn, + cpu, + memory, + requiresCompatibilities: ['FARGATE'], + containerDefinitions: pulumi.jsonStringify(containerDefinitions), + volumes: taskDefinitionVolumes, + tags: { ...commonTags, ...tags }, + }, + { parent: this }, + ); } private createTaskDefinitionVolumes(