t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/AWS/ECS/Service.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

873 lines
33 KiB
TypeScript

import * as AWS from "@/AWS";
import { Subnet } from "@/AWS/EC2/Subnet.ts";
import { Cluster } from "@/AWS/ECS/Cluster.ts";
import { Service, ServiceDidNotStabilize } from "@/AWS/ECS/Service.ts";
import * as Provider from "@/Provider";
import { isResourceState, State, type ResourceState } from "@/State";
import * as Test from "@/Test/Alchemy";
import * as ec2 from "@distilled.cloud/aws/ec2";
import * as ecs from "@distilled.cloud/aws/ecs";
import * as iam from "@distilled.cloud/aws/iam";
import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
import { expect } from "alchemy-test";
import * as Effect from "effect/Effect";
import * as Result from "effect/Result";
import * as Schedule from "effect/Schedule";
import { getDefaultVpc } from "../DefaultVpc.ts";
import { reclaimTaskDefinitionFamily } from "./reclaimTaskDefinitionFamily.ts";
const { test } = Test.make({ providers: AWS.providers() });
// Canonical `list()` test (AWS account/region collection, fanned out per
// cluster): deploy a real cluster + service, resolve the provider from context
// via the typed `findProvider`, call `list()`, and assert the deployed service
// appears in the exhaustively-paginated result
// (listClusters -> listServices -> describeServices hydration).
//
// To stay inside the speed budget we avoid building/pushing a Docker image
// (the canonical `AWS.ECS.Task` resource) and instead register a minimal task
// definition against a public image and run the service at `desiredCount: 0`,
// so `createService` returns immediately without waiting for Fargate task
// placement. Networking is a stack-owned subnet in the standing default VPC
// (no NAT/IGW needed since no task is ever launched).
test.provider(
"list enumerates the deployed service",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
// Reclaim any revisions a previously-killed run left behind, and
// guarantee full deletion (deregister + delete) on success, failure,
// and interruption.
const family = "alchemy-test-ecs-service-list";
yield* reclaimTaskDefinitionFamily(family);
yield* Effect.addFinalizer(() =>
reclaimTaskDefinitionFamily(family).pipe(Effect.ignore),
);
// Register a minimal Fargate task definition pointing at a public image.
const registered = yield* ecs.registerTaskDefinition({
family,
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
cpu: "256",
memory: "512",
containerDefinitions: [
{
name: "app",
image: "public.ecr.aws/nginx/nginx:stable",
essential: true,
portMappings: [{ containerPort: 80, protocol: "tcp" }],
},
],
});
const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn;
if (!taskDefinitionArn) {
return yield* Effect.die(
new Error("registerTaskDefinition returned no task definition ARN"),
);
}
const defaultVpc = yield* getDefaultVpc;
const service = yield* stack.deploy(
Effect.gen(function* () {
const subnet = yield* Subnet("ListServiceSubnet", {
vpcId: defaultVpc.vpcId,
cidrBlock: defaultVpc.subnetCidrBlock(234),
});
const cluster = yield* Cluster("ListServiceCluster", {
clusterName: "alchemy-test-ecs-service-list",
});
return yield* Service("ListService", {
cluster,
task: {
taskDefinitionArn,
containerName: "app",
port: 80,
},
desiredCount: 0,
vpcId: defaultVpc.vpcId,
subnets: [subnet.subnetId],
});
}),
);
const provider = yield* Provider.findProvider(Service);
const all = yield* provider.list();
expect(all.some((s) => s.serviceArn === service.serviceArn)).toBe(true);
const found = all.find((s) => s.serviceArn === service.serviceArn);
expect(found?.serviceName).toEqual(service.serviceName);
expect(found?.clusterArn).toEqual(service.clusterArn);
yield* stack.destroy();
yield* reclaimTaskDefinitionFamily(family);
// Out-of-band gone-proof: the cluster (deleted after its service) is
// INACTIVE or absent, so nothing this test created is left ACTIVE.
const after = yield* ecs.describeClusters({
clusters: ["alchemy-test-ecs-service-list"],
});
expect((after.clusters ?? []).some((c) => c.status === "ACTIVE")).toBe(
false,
);
}),
{ tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"] },
);
// In-place reconcile coverage: create a service at desiredCount 0 (so
// createService returns immediately without waiting on Fargate placement),
// then update the network configuration (assignPublicIp), desiredCount, the
// deployment circuit breaker, and tags — and assert the service was updated in
// place (SAME serviceArn, no replacement). This exercises the per-aspect
// updateService + tag-sync path and the narrowed `diff` (these fields must NOT
// force a replace).
test.provider(
"service applies network / desiredCount / deployment / tag changes in place",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const family = "alchemy-test-ecs-service-inplace";
yield* reclaimTaskDefinitionFamily(family);
yield* Effect.addFinalizer(() =>
reclaimTaskDefinitionFamily(family).pipe(Effect.ignore),
);
const registered = yield* ecs.registerTaskDefinition({
family,
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
cpu: "256",
memory: "512",
containerDefinitions: [
{
name: "app",
image: "public.ecr.aws/nginx/nginx:stable",
essential: true,
portMappings: [{ containerPort: 80, protocol: "tcp" }],
},
],
});
const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!;
const defaultVpc = yield* getDefaultVpc;
const deployService = (props: {
desiredCount: number;
assignPublicIp: boolean;
tags: Record<string, string>;
circuitBreaker: boolean;
}) =>
stack.deploy(
Effect.gen(function* () {
const subnet = yield* Subnet("InPlaceSubnet", {
vpcId: defaultVpc.vpcId,
cidrBlock: defaultVpc.subnetCidrBlock(235),
});
const cluster = yield* Cluster("InPlaceCluster", {
clusterName: "alchemy-test-ecs-service-inplace",
});
return yield* Service("InPlaceService", {
cluster,
task: { taskDefinitionArn, containerName: "app", port: 80 },
desiredCount: props.desiredCount,
vpcId: defaultVpc.vpcId,
subnets: [subnet.subnetId],
assignPublicIp: props.assignPublicIp,
tags: props.tags,
deploymentConfiguration: {
minimumHealthyPercent: 100,
maximumPercent: 200,
deploymentCircuitBreaker: {
enable: props.circuitBreaker,
rollback: props.circuitBreaker,
},
},
});
}),
);
const created = yield* deployService({
desiredCount: 0,
assignPublicIp: false,
tags: { env: "test", keep: "v1" },
circuitBreaker: false,
});
const updated = yield* deployService({
desiredCount: 0,
assignPublicIp: true,
tags: { env: "test", added: "new" },
circuitBreaker: true,
});
// No replacement — identity is stable.
expect(updated.serviceArn).toEqual(created.serviceArn);
// Verify out-of-band that updateService applied the changes.
const described = yield* ecs.describeServices({
cluster: updated.clusterArn,
services: [updated.serviceName],
include: ["TAGS"],
});
const svc = described.services?.[0];
expect(svc?.serviceArn).toEqual(created.serviceArn);
expect(svc?.taskDefinition).toEqual(taskDefinitionArn);
expect(svc?.runningCount).toBe(0);
expect(svc?.pendingCount).toBe(0);
expect(svc?.deployments).toHaveLength(1);
expect(svc?.deployments?.[0]?.status).toBe("PRIMARY");
expect(svc?.deployments?.[0]?.rolloutState).toBe("COMPLETED");
expect(
svc?.deploymentConfiguration?.deploymentCircuitBreaker?.enable,
).toBe(true);
expect(
svc?.networkConfiguration?.awsvpcConfiguration?.assignPublicIp,
).toBe("ENABLED");
// Tag reconcile: `keep` removed, `added` present, `env` retained.
const tagMap = Object.fromEntries(
(svc?.tags ?? []).map((t) => [t.key, t.value]),
);
expect(tagMap.added).toBe("new");
expect(tagMap.env).toBe("test");
expect(tagMap.keep).toBeUndefined();
yield* stack.destroy();
yield* reclaimTaskDefinitionFamily(family);
// Out-of-band gone-proof: the cluster (deleted after its service) is
// INACTIVE or absent.
const after = yield* ecs.describeClusters({
clusters: ["alchemy-test-ecs-service-inplace"],
});
expect((after.clusters ?? []).some((c) => c.status === "ACTIVE")).toBe(
false,
);
}),
{
tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
timeout: 240_000,
},
);
test.provider(
"a stale failed deployment does not fail the replacement deployment",
(stack) =>
Effect.gen(function* () {
const family = "alchemy-test-ecs-service-failed-rollout";
const stableImage = "public.ecr.aws/nginx/nginx:stable";
const failingImage = "public.ecr.aws/docker/library/alpine:3.20";
const registerTaskDefinition = (image: string, command?: string[]) =>
ecs.registerTaskDefinition({
family,
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
cpu: "256",
memory: "512",
containerDefinitions: [
{
name: "app",
image,
command,
essential: true,
portMappings: [{ containerPort: 80, protocol: "tcp" }],
},
],
});
const deployService = (
taskDefinitionArn: string,
generation: string,
desiredCount: number,
) =>
stack.deploy(
Effect.gen(function* () {
const defaultVpc = yield* getDefaultVpc;
const subnet = yield* Subnet("FailedRolloutSubnet", {
vpcId: defaultVpc.vpcId,
cidrBlock: defaultVpc.subnetCidrBlock(246),
});
const cluster = yield* Cluster("FailedRolloutCluster", {
clusterName: family,
});
return yield* Service("FailedRolloutService", {
cluster,
task: { taskDefinitionArn, containerName: "app", port: 80 },
desiredCount,
vpcId: defaultVpc.vpcId,
subnets: [subnet.subnetId],
assignPublicIp: true,
tags: { generation },
deploymentStabilizationTimeout: "8 minutes",
deploymentConfiguration: {
minimumHealthyPercent: 0,
maximumPercent: 200,
deploymentCircuitBreaker: { enable: true, rollback: false },
},
});
}),
);
// Register cleanup before creating out-of-band task definitions.
yield* stack.destroy();
yield* reclaimTaskDefinitionFamily(family);
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* stack.destroy().pipe(Effect.ignore);
yield* reclaimTaskDefinitionFamily(family).pipe(Effect.ignore);
}),
);
const initialTask = yield* registerTaskDefinition(stableImage);
const failingTask = yield* registerTaskDefinition(failingImage, [
"sh",
"-c",
"exit 1",
]);
const recoveryTask = yield* registerTaskDefinition(stableImage);
const initialTaskArn = initialTask.taskDefinition?.taskDefinitionArn!;
const failingTaskArn = failingTask.taskDefinition?.taskDefinitionArn!;
const recoveryTaskArn = recoveryTask.taskDefinition?.taskDefinitionArn!;
const created = yield* deployService(initialTaskArn, "initial", 0);
const describeService = ecs.describeServices({
cluster: created.clusterArn,
services: [created.serviceName],
});
const initialSnapshot = yield* describeService;
const failed = yield* deployService(failingTaskArn, "failed", 1).pipe(
Effect.result,
);
expect(Result.isFailure(failed)).toBe(true);
if (Result.isFailure(failed)) {
expect(failed.failure).toBeInstanceOf(ServiceDidNotStabilize);
if (failed.failure instanceof ServiceDidNotStabilize) {
expect(failed.failure.message).toContain(
"reported a failed deployment",
);
expect(failed.failure.expectedTaskDefinitionArn).toBe(failingTaskArn);
}
}
const failedSnapshot = yield* describeService;
const failedDeployment = failedSnapshot.services?.[0]?.deployments?.find(
(deployment) => deployment.status === "PRIMARY",
);
expect(failedDeployment?.id).toBeDefined();
expect(failedDeployment?.taskDefinition).toBe(failingTaskArn);
expect(failedDeployment?.rolloutState).toBe("FAILED");
const failedDeploymentId = failedDeployment!.id!;
const [recovered, overlapping] = yield* Effect.all(
[
deployService(recoveryTaskArn, "recovered", 1),
describeService.pipe(
Effect.repeat({
schedule: Schedule.spaced("1 second"),
times: 120,
until: (response) => {
const deployments = response.services?.[0]?.deployments ?? [];
const primary = deployments.find((d) => d.status === "PRIMARY");
return (
primary?.taskDefinition === recoveryTaskArn &&
primary.id !== failedDeploymentId &&
(primary.rolloutState === "COMPLETED" ||
deployments.some(
(d) =>
d.id === failedDeploymentId &&
d.rolloutState === "FAILED",
))
);
},
}),
),
],
{ concurrency: 2 },
);
expect(recovered.serviceArn).toBe(created.serviceArn);
expect(recovered.taskDefinitionArn).toBe(recoveryTaskArn);
const overlappingDeployments =
overlapping.services?.[0]?.deployments ?? [];
expect(
overlappingDeployments.some(
(d) => d.id === failedDeploymentId && d.rolloutState === "FAILED",
),
).toBe(true);
const recoveryDeploymentId = overlappingDeployments.find(
(d) => d.status === "PRIMARY",
)?.id;
expect(recoveryDeploymentId).toBeDefined();
expect(recoveryDeploymentId).not.toBe(failedDeploymentId);
const recoveredSnapshot = yield* describeService;
const recoveredService = recoveredSnapshot.services?.[0];
expect(recoveredService?.createdAt).toEqual(
initialSnapshot.services?.[0]?.createdAt,
);
expect(recoveredService?.deployments).toHaveLength(1);
expect(recoveredService?.deployments?.[0]?.id).toBe(recoveryDeploymentId);
expect(recoveredService?.deployments?.[0]?.rolloutState).toBe(
"COMPLETED",
);
expect(recoveredService?.runningCount).toBe(1);
expect(recoveredService?.pendingCount).toBe(0);
yield* deployService(recoveryTaskArn, "forced-same-revision", 1);
const forcedSnapshot = yield* describeService;
const forcedService = forcedSnapshot.services?.[0];
expect(forcedService?.deployments).toHaveLength(1);
expect(forcedService?.deployments?.[0]?.id).not.toBe(
recoveryDeploymentId,
);
expect(forcedService?.deployments?.[0]?.taskDefinition).toBe(
recoveryTaskArn,
);
expect(forcedService?.deployments?.[0]?.rolloutState).toBe("COMPLETED");
expect(forcedService?.runningCount).toBe(1);
expect(forcedService?.pendingCount).toBe(0);
yield* stack.destroy();
yield* reclaimTaskDefinitionFamily(family);
const after = yield* ecs.describeClusters({ clusters: [family] });
expect((after.clusters ?? []).some((c) => c.status === "ACTIVE")).toBe(
false,
);
}),
{
tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
timeout: 900_000,
},
);
// Manual (user-supplied) load balancer: create an ALB + target group OUT OF
// BAND, pass it explicitly via `loadBalancers` with `public: false`, and assert
// (a) no Alchemy-managed ALB was created (no `url`/`loadBalancerArn` on the
// attributes) and (b) the service is wired to the supplied target group.
//
// ECS `createService` rejects a target group that is not associated with a load
// balancer (`InvalidParameterException`), so we provision a real (but minimal)
// ALB + listener + target group out of band across two AZ subnets, wire the
// service to that target group with `public: false`, and assert no
// Alchemy-managed ALB was created.
test.provider(
"service wires a user-supplied target group without creating an ALB",
(stack) =>
Effect.gen(function* () {
// Clean up any out-of-band ELBv2 leftovers from a prior interrupted run
// BEFORE destroying the stack: a stale ALB holds ENIs in the
// stack-owned subnets and prevents their deletion. It also keeps the
// fresh ALB, target group, and listener consistently wired (a stale,
// unassociated target group would make `createService` reject with
// `InvalidParameterException`).
const existingLbs = yield* elbv2
.describeLoadBalancers({ Names: ["alchemy-test-ecs-manuallb"] })
.pipe(Effect.catch(() => Effect.succeed({ LoadBalancers: [] })));
for (const lb of existingLbs.LoadBalancers ?? []) {
const ls = yield* elbv2
.describeListeners({ LoadBalancerArn: lb.LoadBalancerArn! })
.pipe(Effect.catch(() => Effect.succeed({ Listeners: [] })));
for (const l of ls.Listeners ?? []) {
yield* elbv2
.deleteListener({ ListenerArn: l.ListenerArn! })
.pipe(Effect.catch(() => Effect.void));
}
yield* elbv2
.deleteLoadBalancer({ LoadBalancerArn: lb.LoadBalancerArn! })
.pipe(Effect.catch(() => Effect.void));
}
if ((existingLbs.LoadBalancers ?? []).length > 0) {
yield* Effect.sleep("8 seconds");
}
const existingTgs = yield* elbv2
.describeTargetGroups({ Names: ["alchemy-test-ecs-manuallb"] })
.pipe(Effect.catch(() => Effect.succeed({ TargetGroups: [] })));
for (const tg of existingTgs.TargetGroups ?? []) {
yield* elbv2
.deleteTargetGroup({ TargetGroupArn: tg.TargetGroupArn! })
.pipe(
Effect.retry({
while: (e) => e._tag === "ResourceInUseException",
schedule: Schedule.max([
Schedule.spaced("3 seconds"),
Schedule.recurs(5),
]),
}),
Effect.catch(() => Effect.void),
);
}
yield* stack.destroy();
const family = "alchemy-test-ecs-service-manuallb";
yield* reclaimTaskDefinitionFamily(family);
yield* Effect.addFinalizer(() =>
reclaimTaskDefinitionFamily(family).pipe(Effect.ignore),
);
const registered = yield* ecs.registerTaskDefinition({
family,
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
cpu: "256",
memory: "512",
containerDefinitions: [
{
name: "app",
image: "public.ecr.aws/nginx/nginx:stable",
essential: true,
portMappings: [{ containerPort: 80, protocol: "tcp" }],
},
],
});
const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!;
const defaultVpc = yield* getDefaultVpc;
// Resolve two available AZs in the active region — hardcoding zone
// names breaks as soon as the profile targets a different region.
const azResult = yield* ec2.describeAvailabilityZones({});
const availableAzs = (azResult.AvailabilityZones ?? []).filter(
(az) => az.State === "available",
);
const az1 = availableAzs[0]?.ZoneName!;
const az2 = availableAzs[1]?.ZoneName!;
// Deploy networking first so we can resolve concrete ids for the
// out-of-band ELBv2 resources.
const net = yield* stack.deploy(
Effect.gen(function* () {
const subnetA = yield* Subnet("ManualLbSubnetA", {
vpcId: defaultVpc.vpcId,
cidrBlock: defaultVpc.subnetCidrBlock(236),
availabilityZone: az1,
});
const subnetB = yield* Subnet("ManualLbSubnetB", {
vpcId: defaultVpc.vpcId,
cidrBlock: defaultVpc.subnetCidrBlock(237),
availabilityZone: az2,
});
const cluster = yield* Cluster("ManualLbCluster", {
clusterName: "alchemy-test-ecs-service-manuallb",
});
return {
vpcId: defaultVpc.vpcId,
subnetAId: subnetA.subnetId.as<string>(),
subnetBId: subnetB.subnetId.as<string>(),
clusterArn: cluster.clusterArn.as<string>(),
};
}),
);
// Create a real (internal) ALB + target group + listener out of band so
// the target group is associated with a load balancer.
const loadBalancer = yield* elbv2.createLoadBalancer({
Name: "alchemy-test-ecs-manuallb",
Type: "application",
Scheme: "internal",
Subnets: [net.subnetAId, net.subnetBId],
});
const loadBalancerArn = loadBalancer.LoadBalancers?.[0]?.LoadBalancerArn!;
// Safety-net finalizers (run LIFO on scope close): listener -> TG -> ALB,
// so the out-of-band ELBv2 resources are reclaimed even if the body fails.
yield* Effect.addFinalizer(() =>
elbv2
.deleteLoadBalancer({ LoadBalancerArn: loadBalancerArn })
.pipe(Effect.ignore),
);
const targetGroup = yield* elbv2.createTargetGroup({
Name: "alchemy-test-ecs-manuallb",
VpcId: net.vpcId,
TargetType: "ip",
Protocol: "HTTP",
Port: 80,
});
const targetGroupArn = targetGroup.TargetGroups?.[0]?.TargetGroupArn!;
yield* Effect.addFinalizer(() =>
elbv2
.deleteTargetGroup({ TargetGroupArn: targetGroupArn })
.pipe(Effect.ignore),
);
const listener = yield* elbv2.createListener({
LoadBalancerArn: loadBalancerArn,
Port: 80,
Protocol: "HTTP",
DefaultActions: [{ Type: "forward", TargetGroupArn: targetGroupArn }],
});
const listenerArn = listener.Listeners?.[0]?.ListenerArn!;
yield* Effect.addFinalizer(() =>
elbv2.deleteListener({ ListenerArn: listenerArn }).pipe(Effect.ignore),
);
// Re-declare the same networking (idempotent — same logical ids) plus the
// Service wired to the user-supplied target group.
const service = yield* stack.deploy(
Effect.gen(function* () {
const subnetA = yield* Subnet("ManualLbSubnetA", {
vpcId: defaultVpc.vpcId,
cidrBlock: defaultVpc.subnetCidrBlock(236),
availabilityZone: az1,
});
const subnetB = yield* Subnet("ManualLbSubnetB", {
vpcId: defaultVpc.vpcId,
cidrBlock: defaultVpc.subnetCidrBlock(237),
availabilityZone: az2,
});
const cluster = yield* Cluster("ManualLbCluster", {
clusterName: "alchemy-test-ecs-service-manuallb",
});
return yield* Service("ManualLbService", {
cluster,
task: { taskDefinitionArn, containerName: "app", port: 80 },
desiredCount: 0,
public: false,
vpcId: defaultVpc.vpcId,
subnets: [subnetA.subnetId, subnetB.subnetId],
loadBalancers: [
{ targetGroupArn, containerName: "app", containerPort: 80 },
],
// Duration.Input audit coverage: only valid on services with a
// load balancer — assert the wire value round-trips as seconds.
healthCheckGracePeriod: "45 seconds",
});
}),
);
// No Alchemy-managed ALB.
expect(service.loadBalancerArn).toBeUndefined();
expect(service.url).toBeUndefined();
const described = yield* ecs.describeServices({
cluster: service.clusterArn,
services: [service.serviceName],
});
const lbs = described.services?.[0]?.loadBalancers ?? [];
expect(lbs.length).toBe(1);
expect(lbs[0]?.containerName).toBe("app");
expect(lbs[0]?.targetGroupArn).toBe(targetGroupArn);
// Duration.Input round-trip: `"45 seconds"` landed on the wire as 45.
expect(described.services?.[0]?.healthCheckGracePeriodSeconds).toBe(45);
// Delete the service first (it references the target group), then tear
// down the out-of-band ELBv2 resources, then the rest of the stack.
yield* ecs
.deleteService({
cluster: service.clusterArn,
service: service.serviceName,
force: true,
})
.pipe(Effect.catch(() => Effect.void));
yield* elbv2
.deleteListener({ ListenerArn: listenerArn })
.pipe(Effect.catch(() => Effect.void));
yield* elbv2
.deleteLoadBalancer({ LoadBalancerArn: loadBalancerArn })
.pipe(Effect.catch(() => Effect.void));
yield* elbv2
.deleteTargetGroup({ TargetGroupArn: targetGroupArn })
.pipe(Effect.catch(() => Effect.void));
yield* stack.destroy();
yield* reclaimTaskDefinitionFamily(family);
}),
{
tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
timeout: 240_000,
},
);
// Regression test for https://github.com/alchemy-run/alchemy/issues/736.
//
// An interrupted first deploy persists the service as `status: "creating"`
// with no attributes — and the Output-valued props (`cluster` passed as a
// resource object, `task`, `vpcId`, `subnets`) do not survive the state
// round-trip: they deserialize as `undefined`. Plan's recovery branch then
// calls `provider.read` with those junk props, which crashed in
// `clusterArnOf(undefined)` and wedged the stack (plan/deploy/destroy all
// build a plan, so there was no CLI escape hatch).
//
// Simulate exactly that state row after a real deploy and assert the next
// deploy recovers: `read` reports "not found", the engine re-drives the
// create, and reconcile converges on the half-created service by name —
// SAME serviceArn, no replacement.
test.provider(
"recovers a half-created service whose creating-state lost Output-valued props (#736)",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const family = "alchemy-test-ecs-service-wedged";
yield* reclaimTaskDefinitionFamily(family);
yield* Effect.addFinalizer(() =>
reclaimTaskDefinitionFamily(family).pipe(Effect.ignore),
);
const registered = yield* ecs.registerTaskDefinition({
family,
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
cpu: "256",
memory: "512",
containerDefinitions: [
{
name: "app",
image: "public.ecr.aws/nginx/nginx:stable",
essential: true,
portMappings: [{ containerPort: 80, protocol: "tcp" }],
},
],
});
const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!;
const defaultVpc = yield* getDefaultVpc;
const deployService = () =>
stack.deploy(
Effect.gen(function* () {
const subnet = yield* Subnet("WedgedSubnet", {
vpcId: defaultVpc.vpcId,
cidrBlock: defaultVpc.subnetCidrBlock(238),
});
const cluster = yield* Cluster("WedgedCluster", {
clusterName: "alchemy-test-ecs-service-wedged",
});
return yield* Service("WedgedService", {
// Whole resource object, NOT a string ARN — the #736 shape.
cluster,
task: { taskDefinitionArn, containerName: "app", port: 80 },
desiredCount: 0,
vpcId: defaultVpc.vpcId,
subnets: [subnet.subnetId],
});
}),
);
const created = yield* deployService();
// Rewrite the service's persisted row into the wedged shape an
// interrupted deploy leaves behind: `creating`, no attributes, and
// every Output-valued prop lost in the round-trip.
const state = yield* yield* State;
const stage = stack.stage;
const fqns = yield* state.list({ stack: stack.name, stage });
const rows = yield* Effect.forEach(fqns, (fqn) =>
state
.get({ stack: stack.name, stage, fqn })
.pipe(Effect.map((row) => ({ fqn, row }))),
);
const wedged = rows.find(
(r): r is { fqn: string; row: ResourceState } =>
isResourceState(r.row) && r.row.resourceType === "AWS.ECS.Service",
);
if (!wedged) {
return yield* Effect.die(
new Error("no AWS.ECS.Service state row found after deploy"),
);
}
yield* state.set({
stack: stack.name,
stage,
fqn: wedged.fqn,
value: {
...wedged.row,
status: "creating",
attr: undefined,
props: {
...wedged.row.props,
cluster: undefined,
task: undefined,
vpcId: undefined,
subnets: undefined,
},
},
});
// Before the fix this crashed in plan with
// `TypeError: undefined is not an object (evaluating 'cluster.clusterArn')`.
const recovered = yield* deployService();
expect(recovered.serviceArn).toEqual(created.serviceArn);
expect(recovered.clusterArn).toEqual(created.clusterArn);
yield* stack.destroy();
yield* reclaimTaskDefinitionFamily(family);
}),
{
tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
timeout: 240_000,
},
);
// Regression: the image-owning `ServiceProps` form inherits
// `taskRoleManagedPolicyArns` from `TaskDefinitionConfig`, but reconcile
// used to honor only its sibling `executionRoleManagedPolicyArns` — services
// deployed with task roles silently missing their declared permissions.
// Deploy an image-form service (desiredCount 0, no ingress) declaring an
// AWS-managed policy, verify out of band that the policy is attached to the
// task role, redeploy to prove the attach is idempotent, and verify destroy
// detaches the policy and deletes the role.
test.provider(
"attaches taskRoleManagedPolicyArns to the task role (image form)",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const managedPolicyArn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess";
const deployService = () =>
stack.deploy(
Effect.gen(function* () {
const cluster = yield* Cluster("TaskRolePolicyCluster", {
clusterName: "alchemy-test-ecs-service-task-role-policy",
});
return yield* Service("TaskRolePolicyService", {
cluster,
image: "busybox:stable",
command: ["sh", "-c", "while true; do sleep 30; done"],
port: 80,
desiredCount: 0,
taskRoleManagedPolicyArns: [managedPolicyArn],
});
}),
);
const created = yield* deployService();
const taskRoleName = created.taskRoleName;
if (!taskRoleName) {
return yield* Effect.die(
new Error("image-form service returned no taskRoleName"),
);
}
const listAttachedArns = iam
.listAttachedRolePolicies({ RoleName: taskRoleName })
.pipe(
Effect.map((r) => (r.AttachedPolicies ?? []).map((p) => p.PolicyArn)),
);
expect(yield* listAttachedArns).toContain(managedPolicyArn);
// Second deploy re-runs the attach against an already-attached ARN —
// `attachRolePolicy` is idempotent, so reconcile must not fail.
const updated = yield* deployService();
expect(updated.serviceArn).toEqual(created.serviceArn);
expect(yield* listAttachedArns).toContain(managedPolicyArn);
yield* stack.destroy();
// Gone-proof: destroy detached the managed policy and deleted the
// task role.
const roleGone = yield* iam.getRole({ RoleName: taskRoleName }).pipe(
Effect.map(() => false),
Effect.catchTag("NoSuchEntityException", () => Effect.succeed(true)),
);
expect(roleGone).toBe(true);
}),
{
tags: ["provider:aws", "provider:aws:ecs", "provider:aws:iam", "live"],
timeout: 240_000,
},
);