| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Project } from "@/AWS/CodeBuild/Project.ts"; |
| 3 | import { |
| 4 | normalizePolicyDocument, |
| 5 | type PolicyDocument, |
| 6 | } from "@/AWS/IAM/Policy.ts"; |
| 7 | import * as Provider from "@/Provider"; |
| 8 | import * as Test from "@/Test/Alchemy"; |
| 9 | import * as codebuild from "@distilled.cloud/aws/codebuild"; |
| 10 | import * as sts from "@distilled.cloud/aws/sts"; |
| 11 | import { expect } from "alchemy-test"; |
| 12 | import * as Data from "effect/Data"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Schedule from "effect/Schedule"; |
| 15 | |
| 16 | const { test } = Test.make({ providers: AWS.providers() }); |
| 17 | |
| 18 | const projectName = "alchemy-test-codebuild-project"; |
| 19 | |
| 20 | const buildspec = [ |
| 21 | "version: 0.2", |
| 22 | "phases:", |
| 23 | " build:", |
| 24 | " commands:", |
| 25 | " - echo Hello from CodeBuild", |
| 26 | ].join("\n"); |
| 27 | |
| 28 | const 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 | |
| 58 | const getProject = codebuild |
| 59 | .batchGetProjects({ names: [projectName] }) |
| 60 | .pipe(Effect.map((res) => res.projects?.[0])); |
| 61 | |
| 62 | class CodeBuildRoleNotReady extends Data.TaggedError("CodeBuildRoleNotReady")<{ |
| 63 | readonly buildId: string; |
| 64 | readonly message: string; |
| 65 | }> {} |
| 66 | |
| 67 | test.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. |
| 212 | test.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 | ); |