1import * as AWS from "@/AWS";
2import { Project } from "@/AWS/CodeBuild/Project.ts";
3import {
4 normalizePolicyDocument,
5 type PolicyDocument,
6} from "@/AWS/IAM/Policy.ts";
7import * as Provider from "@/Provider";
8import * as Test from "@/Test/Alchemy";
9import * as codebuild from "@distilled.cloud/aws/codebuild";
10import * as sts from "@distilled.cloud/aws/sts";
11import { expect } from "alchemy-test";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as Schedule from "effect/Schedule";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18const projectName = "alchemy-test-codebuild-project";
19
20const buildspec = [
21 "version: 0.2",
22 "phases:",
23 " build:",
24 " commands:",
25 " - echo Hello from CodeBuild",
26].join("\n");
27
28const codebuildRole = (logical: string) =>
29 AWS.IAM.Role(logical, {
30 assumeRolePolicyDocument: {
31 Version: "2012-10-17",
32 Statement: [
33 {
34 Effect: "Allow",
35 Principal: { Service: "codebuild.amazonaws.com" },
36 Action: ["sts:AssumeRole"],
37 },
38 ],
39 },
40 inlinePolicies: {
41 Logs: {
42 Version: "2012-10-17",
43 Statement: [
44 {
45 Effect: "Allow",
46 Action: [
47 "logs:CreateLogGroup",
48 "logs:CreateLogStream",
49 "logs:PutLogEvents",
50 ],
51 Resource: ["*"],
52 },
53 ],
54 },
55 },
56 });
57
58const getProject = codebuild
59 .batchGetProjects({ names: [projectName] })
60 .pipe(Effect.map((res) => res.projects?.[0]));
61
62class CodeBuildRoleNotReady extends Data.TaggedError("CodeBuildRoleNotReady")<{
63 readonly buildId: string;
64 readonly message: string;
65}> {}
66
67test.provider(
68 "lifecycle: create NO_SOURCE project, update, destroy",
69 (stack) =>
70 Effect.gen(function* () {
71 yield* stack.destroy();
72
73 // Create — a NO_SOURCE project with an inline buildspec. Defining a
74 // project is instant; no build is run.
75 const deployed = yield* stack.deploy(
76 Effect.gen(function* () {
77 const role = yield* codebuildRole("ProjectRole");
78 return yield* Project("LifecycleProject", {
79 projectName,
80 serviceRole: role.roleArn,
81 source: { type: "NO_SOURCE", buildspec },
82 environment: {
83 image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
84 computeType: "BUILD_GENERAL1_SMALL",
85 environmentVariables: [{ name: "STAGE", value: "dev" }],
86 },
87 });
88 }),
89 );
90 expect(deployed.projectArn).toContain(`project/${projectName}`);
91
92 // Out-of-band verification via distilled.
93 const created = yield* getProject;
94 expect(created?.name).toBe(projectName);
95 expect(created?.source?.type).toBe("NO_SOURCE");
96 expect(created?.environment?.computeType).toBe("BUILD_GENERAL1_SMALL");
97 expect(
98 created?.environment?.environmentVariables?.find(
99 (v) => v.name === "STAGE",
100 )?.value,
101 ).toBe("dev");
102
103 // Canonical list() coverage.
104 const provider = yield* Provider.findProvider(Project);
105 const all = yield* provider.list();
106 expect(all.some((p) => p.projectName === projectName)).toBe(true);
107
108 // Update — change an env var and timeout in place (no replacement),
109 // and attach a typed PolicyDocument resource policy sharing the
110 // project with this account's root principal.
111 const identity = yield* sts.getCallerIdentity({});
112 const resourcePolicy: PolicyDocument = {
113 Version: "2012-10-17",
114 Statement: [
115 {
116 Sid: "Share",
117 Effect: "Allow",
118 Principal: { AWS: `arn:aws:iam::${identity.Account}:root` },
119 Action: ["codebuild:BatchGetProjects"],
120 Resource: deployed.projectArn,
121 },
122 ],
123 };
124 const updateProject = Effect.gen(function* () {
125 const role = yield* codebuildRole("ProjectRole");
126 return yield* Project("LifecycleProject", {
127 projectName,
128 serviceRole: role.roleArn,
129 source: { type: "NO_SOURCE", buildspec },
130 environment: {
131 image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
132 computeType: "BUILD_GENERAL1_MEDIUM",
133 environmentVariables: [{ name: "STAGE", value: "prod" }],
134 },
135 timeout: "30 minutes",
136 resourcePolicy,
137 });
138 });
139 yield* stack.deploy(updateProject);
140 const updated = yield* getProject;
141 expect(updated?.environment?.computeType).toBe("BUILD_GENERAL1_MEDIUM");
142 expect(
143 updated?.environment?.environmentVariables?.find(
144 (v) => v.name === "STAGE",
145 )?.value,
146 ).toBe("prod");
147 expect(updated?.timeoutInMinutes).toBe(30);
148
149 // The PolicyDocument round-trips through the wire as a string that
150 // normalizes back to exactly the desired document.
151 const readPolicy = codebuild
152 .getResourcePolicy({ resourceArn: deployed.projectArn })
153 .pipe(
154 Effect.map((res) => res.policy),
155 Effect.catchTag("ResourceNotFoundException", () =>
156 Effect.succeed(undefined),
157 ),
158 );
159 const attached = yield* readPolicy;
160 expect(attached).toBeDefined();
161 expect(normalizePolicyDocument(attached!)).toBe(
162 normalizePolicyDocument(resourcePolicy),
163 );
164
165 // Re-deploy the identical PolicyDocument — the drift comparison is on
166 // normalized documents, so this converges cleanly as a no-op and the
167 // policy is unchanged.
168 yield* stack.deploy(updateProject);
169 const afterRedeploy = yield* readPolicy;
170 expect(normalizePolicyDocument(afterRedeploy!)).toBe(
171 normalizePolicyDocument(resourcePolicy),
172 );
173
174 // Drop the resourcePolicy prop — reconcile deletes the attached policy.
175 yield* stack.deploy(
176 Effect.gen(function* () {
177 const role = yield* codebuildRole("ProjectRole");
178 return yield* Project("LifecycleProject", {
179 projectName,
180 serviceRole: role.roleArn,
181 source: { type: "NO_SOURCE", buildspec },
182 environment: {
183 image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
184 computeType: "BUILD_GENERAL1_MEDIUM",
185 environmentVariables: [{ name: "STAGE", value: "prod" }],
186 },
187 timeout: "30 minutes",
188 });
189 }),
190 );
191 const afterRemoval = yield* readPolicy;
192 expect(afterRemoval === undefined || afterRemoval === "").toBe(true);
193
194 // Destroy — project is deleted; verify it is gone out-of-band.
195 yield* stack.destroy();
196 const after = yield* getProject;
197 expect(after).toBeUndefined();
198 }),
199 {
200 tags: [
201 "provider:aws",
202 "provider:aws:codebuild",
203 "provider:aws:iam",
204 "live",
205 ],
206 timeout: 300_000,
207 },
208);
209
210// Exercise the StartBuild + BatchGetBuilds path end-to-end against a
211// NO_SOURCE project. Lambda compute keeps this within the regular test budget.
212test.provider(
213 "NO_SOURCE build runs to SUCCEEDED",
214 (stack) => {
215 const buildIds: string[] = [];
216 return Effect.gen(function* () {
217 yield* stack.destroy();
218
219 const deployed = yield* stack.deploy(
220 Effect.gen(function* () {
221 const role = yield* codebuildRole("BuildRole");
222 return yield* Project("BuildProject", {
223 projectName: `${projectName}-run`,
224 serviceRole: role.roleArn,
225 source: { type: "NO_SOURCE", buildspec },
226 environment: {
227 image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
228 computeType: "BUILD_GENERAL1_SMALL",
229 },
230 // This smoke build does not consume its logs. Disabling both log
231 // destinations removes a race where CodeBuild assumes a freshly
232 // created role before its CloudWatch Logs policy has propagated.
233 logsConfig: {
234 cloudWatchLogs: { status: "DISABLED" },
235 s3Logs: { status: "DISABLED" },
236 },
237 });
238 }),
239 );
240
241 const configured = yield* codebuild.batchGetProjects({
242 names: [deployed.projectName],
243 });
244 expect(configured.projects?.[0]?.logsConfig?.cloudWatchLogs?.status).toBe(
245 "DISABLED",
246 );
247 expect(configured.projects?.[0]?.logsConfig?.s3Logs?.status).toBe(
248 "DISABLED",
249 );
250
251 const build = yield* Effect.gen(function* () {
252 const started = yield* codebuild.startBuild({
253 projectName: deployed.projectName,
254 });
255 const buildId = started.build?.id;
256 expect(buildId).toBeDefined();
257 buildIds.push(buildId!);
258
259 // Poll until the build reaches a terminal status. IN_QUEUE is not
260 // terminal; bound the total polling delay to 50 seconds.
261 const terminal = yield* codebuild
262 .batchGetBuilds({ ids: [buildId!] })
263 .pipe(
264 Effect.map((res) => res.builds?.[0]),
265 Effect.repeat({
266 schedule: Schedule.spaced("5 seconds"),
267 until: (b) => {
268 const status = b?.buildStatus;
269 return (
270 status === "SUCCEEDED" ||
271 status === "FAILED" ||
272 status === "FAULT" ||
273 status === "STOPPED" ||
274 status === "TIMED_OUT"
275 );
276 },
277 times: 10,
278 }),
279 );
280
281 // createProject validates the role but can return before the role's
282 // trust policy is usable by the CodeBuild data plane. Retry only this
283 // exact queued propagation failure. A buildspec/container failure is
284 // returned unchanged and must fail the SUCCEEDED assertion below.
285 const roleNotReady = terminal?.phases
286 ?.flatMap((phase) => phase.contexts ?? [])
287 .find(
288 (context) =>
289 context.statusCode === "ACCESS_DENIED" &&
290 context.message?.includes("Unable to assume role"),
291 );
292 if (terminal?.buildStatus === "FAILED" && roleNotReady) {
293 yield* Effect.logWarning(
294 `CodeBuild role not ready for ${buildId}: ${roleNotReady.message}`,
295 );
296 return yield* Effect.fail(
297 new CodeBuildRoleNotReady({
298 buildId: buildId!,
299 message: roleNotReady.message ?? "Unable to assume role",
300 }),
301 );
302 }
303 return terminal;
304 }).pipe(
305 Effect.retry({
306 while: (error) => error._tag === "CodeBuildRoleNotReady",
307 schedule: Schedule.spaced("3 seconds"),
308 times: 6,
309 }),
310 );
311 expect(build?.buildStatus).toBe("SUCCEEDED");
312
313 yield* stack.destroy();
314 const after = yield* codebuild.batchGetProjects({
315 names: [`${projectName}-run`],
316 });
317 expect(after.projects?.[0]).toBeUndefined();
318
319 return build;
320 }).pipe(
321 Effect.ensuring(
322 Effect.gen(function* () {
323 yield* Effect.forEach(
324 buildIds,
325 (buildId) =>
326 codebuild.stopBuild({ id: buildId }).pipe(Effect.ignore),
327 { concurrency: 2, discard: true },
328 );
329 yield* stack.destroy().pipe(Effect.ignore);
330 }),
331 ),
332 );
333 },
334 {
335 tags: [
336 "provider:aws",
337 "provider:aws:codebuild",
338 "provider:aws:iam",
339 "live",
340 ],
341 timeout: 120_000,
342 },
343);