1import * as codebuild from "@distilled.cloud/aws/codebuild";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
12import { toWireMinutes } from "../../Util/Duration.ts";
13import {
14 normalizePolicyDocument,
15 stringifyPolicyDocument,
16 type PolicyDocument,
17} from "../IAM/Policy.ts";
18import type { Providers } from "../Providers.ts";
19
20/**
21 * Where CodeBuild fetches the source to build.
22 */
23export interface ProjectSourceConfig {
24 /**
25 * Source provider type. `NO_SOURCE` builds run only the inline `buildspec`
26 * (nothing is fetched); `S3` pulls an object; the git types
27 * (`CODECOMMIT`/`GITHUB`/`GITLAB`/`BITBUCKET`/…) require a matching
28 * source credential or connection.
29 */
30 type:
31 | "NO_SOURCE"
32 | "S3"
33 | "CODECOMMIT"
34 | "CODEPIPELINE"
35 | "GITHUB"
36 | "GITHUB_ENTERPRISE"
37 | "GITLAB"
38 | "GITLAB_SELF_MANAGED"
39 | "BITBUCKET";
40 /**
41 * Source location. Required for every type except `NO_SOURCE` and
42 * `CODEPIPELINE`. For `S3` this is `bucket/path/to/object.zip`; for git
43 * types it is the clone URL.
44 */
45 location?: string;
46 /**
47 * Inline build spec (YAML/JSON). Required when `type` is `NO_SOURCE`;
48 * otherwise overrides the `buildspec.yml` in the source root. May also be
49 * an `arn:aws:s3:::` path to a build spec object.
50 */
51 buildspec?: string;
52 /**
53 * Depth of history to fetch for git sources. `0` fetches full history.
54 */
55 gitCloneDepth?: number;
56 /**
57 * Report the build's start and completion status back to the source
58 * provider (git sources only).
59 */
60 reportBuildStatus?: boolean;
61 /**
62 * Ignore TLS errors when connecting to the source (git sources only).
63 */
64 insecureSsl?: boolean;
65}
66
67/**
68 * Where CodeBuild writes build output artifacts.
69 */
70export interface ProjectArtifactsConfig {
71 /**
72 * Artifact destination. `NO_ARTIFACTS` discards output; `S3` uploads to a
73 * bucket; `CODEPIPELINE` hands artifacts back to a pipeline stage.
74 * @default "NO_ARTIFACTS"
75 */
76 type: "NO_ARTIFACTS" | "S3" | "CODEPIPELINE";
77 /**
78 * Output bucket name (for `type: "S3"`).
79 */
80 location?: string;
81 /**
82 * Path inside the bucket to write to.
83 */
84 path?: string;
85 /**
86 * Whether to prefix the artifact path with the build ID.
87 * @default "NONE"
88 */
89 namespaceType?: "NONE" | "BUILD_ID";
90 /**
91 * Name of the artifact object/folder.
92 */
93 name?: string;
94 /**
95 * Whether the artifact is zipped.
96 * @default "NONE"
97 */
98 packaging?: "NONE" | "ZIP";
99 /**
100 * Use the artifact `name` verbatim instead of appending it to the path.
101 */
102 overrideArtifactName?: boolean;
103 /**
104 * Disable default artifact encryption.
105 */
106 encryptionDisabled?: boolean;
107}
108
109/**
110 * An environment variable exposed to the build container.
111 */
112export interface ProjectEnvironmentVariable {
113 /** Variable name. */
114 name: string;
115 /**
116 * Variable value. For `PLAINTEXT` this is the literal value; for
117 * `PARAMETER_STORE`/`SECRETS_MANAGER` it is the parameter name / secret
118 * ARN to resolve at build time.
119 */
120 value: string;
121 /**
122 * How to interpret `value`.
123 * @default "PLAINTEXT"
124 */
125 type?: "PLAINTEXT" | "PARAMETER_STORE" | "SECRETS_MANAGER";
126}
127
128/**
129 * The build container: image, compute size, and runtime settings.
130 */
131export interface ProjectEnvironmentConfig {
132 /**
133 * Container environment type.
134 * @default "LINUX_CONTAINER"
135 */
136 type?:
137 | "LINUX_CONTAINER"
138 | "LINUX_GPU_CONTAINER"
139 | "ARM_CONTAINER"
140 | "WINDOWS_SERVER_2019_CONTAINER"
141 | "WINDOWS_SERVER_2022_CONTAINER"
142 | "LINUX_LAMBDA_CONTAINER"
143 | "ARM_LAMBDA_CONTAINER"
144 | "MAC_ARM";
145 /**
146 * Docker image to run the build in, e.g.
147 * `aws/codebuild/amazonlinux2-x86_64-standard:5.0`.
148 */
149 image: string;
150 /**
151 * Compute size for the build fleet.
152 * @default "BUILD_GENERAL1_SMALL"
153 */
154 computeType?:
155 | "BUILD_GENERAL1_SMALL"
156 | "BUILD_GENERAL1_MEDIUM"
157 | "BUILD_GENERAL1_LARGE"
158 | "BUILD_GENERAL1_XLARGE"
159 | "BUILD_GENERAL1_2XLARGE"
160 | "BUILD_LAMBDA_1GB"
161 | "BUILD_LAMBDA_2GB"
162 | "BUILD_LAMBDA_4GB"
163 | "BUILD_LAMBDA_8GB"
164 | "BUILD_LAMBDA_10GB";
165 /**
166 * Environment variables available to every build.
167 */
168 environmentVariables?: ProjectEnvironmentVariable[];
169 /**
170 * Run the build container in privileged mode (required to build Docker
171 * images inside the build).
172 * @default false
173 */
174 privilegedMode?: boolean;
175 /**
176 * ARN of an S3 object holding a PEM-encoded certificate to install.
177 */
178 certificate?: string;
179 /**
180 * Credentials used to pull the build image.
181 * @default "CODEBUILD"
182 */
183 imagePullCredentialsType?: "CODEBUILD" | "SERVICE_ROLE";
184}
185
186/** Build log destinations for a CodeBuild project. */
187export interface ProjectLogsConfig {
188 /** CloudWatch Logs delivery configuration. */
189 cloudWatchLogs?: {
190 /** Whether CloudWatch Logs delivery is enabled. */
191 status: "ENABLED" | "DISABLED";
192 /** Optional CloudWatch log group name. */
193 groupName?: string;
194 /** Optional CloudWatch log stream name. */
195 streamName?: string;
196 };
197 /** S3 log delivery configuration. */
198 s3Logs?: {
199 /** Whether S3 log delivery is enabled. */
200 status: "ENABLED" | "DISABLED";
201 /** S3 bucket and prefix for build logs. */
202 location?: string;
203 /** Disable default encryption for S3 build logs. */
204 encryptionDisabled?: boolean;
205 /** Access granted to the destination bucket owner. */
206 bucketOwnerAccess?: "NONE" | "READ_ONLY" | "FULL";
207 };
208}
209
210export interface ProjectProps {
211 /**
212 * Name of the build project (2-255 chars). If omitted a deterministic
213 * physical name is generated. Changing the name replaces the project.
214 */
215 projectName?: string;
216 /**
217 * Description of the project.
218 */
219 description?: string;
220 /**
221 * Source configuration.
222 */
223 source: ProjectSourceConfig;
224 /**
225 * Artifact configuration.
226 * @default { type: "NO_ARTIFACTS" }
227 */
228 artifacts?: ProjectArtifactsConfig;
229 /**
230 * Build container configuration.
231 */
232 environment: ProjectEnvironmentConfig;
233 /**
234 * ARN of the IAM role CodeBuild assumes to run builds. Must trust
235 * `codebuild.amazonaws.com`.
236 */
237 serviceRole: string;
238 /**
239 * How long a build may run before CodeBuild stops it, e.g. `"1 hour"`
240 * (5 minutes to 36 hours). Rounded to whole minutes on the wire.
241 * @default 60 minutes
242 */
243 timeout?: Duration.Input;
244 /**
245 * How long a build may sit queued before it is failed, e.g.
246 * `"30 minutes"` (5 minutes to 8 hours). Rounded to whole minutes on
247 * the wire.
248 */
249 queuedTimeout?: Duration.Input;
250 /**
251 * Maximum number of builds allowed to run concurrently for this project.
252 */
253 concurrentBuildLimit?: number;
254 /**
255 * KMS key ARN/alias used to encrypt build output. Defaults to the
256 * account's default S3 CMK.
257 */
258 encryptionKey?: string;
259 /**
260 * Enable a publicly visible build badge.
261 * @default false
262 */
263 badgeEnabled?: boolean;
264 /**
265 * Build log destinations. Set both destinations to `DISABLED` for builds
266 * that do not need persisted logs.
267 */
268 logsConfig?: ProjectLogsConfig;
269 /**
270 * Resource policy attached to the project — shares the project with other
271 * AWS accounts by granting them read actions such as
272 * `codebuild:BatchGetProjects`. Accepts a typed {@link PolicyDocument} or a
273 * raw JSON string (escape hatch / adoption of an existing policy). Omit to
274 * remove any existing policy.
275 */
276 resourcePolicy?: PolicyDocument | string;
277 /**
278 * User-defined tags.
279 */
280 tags?: Record<string, string>;
281}
282
283export interface Project extends Resource<
284 "AWS.CodeBuild.Project",
285 ProjectProps,
286 {
287 /** Physical name of the build project. */
288 projectName: string;
289 /** ARN of the build project. */
290 projectArn: string;
291 },
292 never,
293 Providers
294> {}
295
296/**
297 * An AWS CodeBuild build project — a reusable definition of how to run a
298 * build: the source, the build container, the compute size, the IAM role,
299 * and where artifacts land.
300 *
301 * The project is a definition only; creating it is instant and free.
302 * Running a build (`StartBuild`) provisions compute and is billed per
303 * build-minute.
304 * ### Creating a Project
305 * **Example:** NO_SOURCE Project with an Inline Buildspec
306 * ```typescript
307 * const project = yield* CodeBuild.Project("Hello", {
308 * serviceRole: role.roleArn,
309 * source: {
310 * type: "NO_SOURCE",
311 * buildspec: [
312 * "version: 0.2",
313 * "phases:",
314 * " build:",
315 * " commands:",
316 * " - echo Hello from CodeBuild",
317 * ].join("\n"),
318 * },
319 * environment: {
320 * image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
321 * computeType: "BUILD_GENERAL1_SMALL",
322 * },
323 * });
324 * ```
325 *
326 * **Example:** S3-Source Project with S3 Artifacts
327 * ```typescript
328 * const project = yield* CodeBuild.Project("Packager", {
329 * serviceRole: role.roleArn,
330 * source: { type: "S3", location: `${bucket.bucketName}/source.zip` },
331 * artifacts: { type: "S3", location: bucket.bucketName, name: "out.zip", packaging: "ZIP" },
332 * environment: {
333 * image: "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
334 * environmentVariables: [{ name: "STAGE", value: "prod" }],
335 * },
336 * });
337 * ```
338 *
339 * @resource
340 */
341export const Project = Resource<Project>("AWS.CodeBuild.Project");
342
343/** Convert a CodeBuild wire tag list into a plain record. */
344const toTagRecord = (
345 tags: ReadonlyArray<{ key?: string; value?: string }> | undefined,
346): Record<string, string> =>
347 Object.fromEntries(
348 (tags ?? [])
349 .filter(
350 (tag): tag is { key: string; value: string } =>
351 typeof tag.key === "string" && typeof tag.value === "string",
352 )
353 .map((tag) => [tag.key, tag.value]),
354 );
355
356const toWireTags = (tags: Record<string, string>): codebuild.Tag[] =>
357 Object.entries(tags).map(([key, value]) => ({ key, value }));
358
359const toWireSource = (
360 source: ProjectSourceConfig,
361): codebuild.ProjectSource => ({
362 type: source.type,
363 location: source.location,
364 buildspec: source.buildspec,
365 gitCloneDepth: source.gitCloneDepth,
366 reportBuildStatus: source.reportBuildStatus,
367 insecureSsl: source.insecureSsl,
368});
369
370const toWireArtifacts = (
371 artifacts: ProjectArtifactsConfig | undefined,
372): codebuild.ProjectArtifacts => ({
373 type: artifacts?.type ?? "NO_ARTIFACTS",
374 location: artifacts?.location,
375 path: artifacts?.path,
376 namespaceType: artifacts?.namespaceType,
377 name: artifacts?.name,
378 packaging: artifacts?.packaging,
379 overrideArtifactName: artifacts?.overrideArtifactName,
380 encryptionDisabled: artifacts?.encryptionDisabled,
381});
382
383const toWireEnvironment = (
384 environment: ProjectEnvironmentConfig,
385): codebuild.ProjectEnvironment => ({
386 type: environment.type ?? "LINUX_CONTAINER",
387 image: environment.image,
388 computeType: environment.computeType ?? "BUILD_GENERAL1_SMALL",
389 environmentVariables: environment.environmentVariables?.map((v) => ({
390 name: v.name,
391 value: v.value,
392 type: v.type,
393 })),
394 privilegedMode: environment.privilegedMode,
395 certificate: environment.certificate,
396 imagePullCredentialsType: environment.imagePullCredentialsType,
397});
398
399/**
400 * CodeBuild validates the service role at create/update time; a freshly
401 * created IAM role is not yet assumable, surfacing as an
402 * `InvalidInputException` whose message mentions the role is not authorized.
403 * Retry (bounded) through IAM propagation. The explicit return annotation
404 * keeps the retry's conditional type out of declaration emit (which would
405 * otherwise widen the provider layer — see PATTERNS §7).
406 */
407const retryIamPropagation = <
408 A,
409 E extends { readonly _tag: string; readonly message?: string },
410 R,
411>(
412 effect: Effect.Effect<A, E, R>,
413): Effect.Effect<A, E, R> =>
414 effect.pipe(
415 Effect.retry({
416 while: (e) =>
417 e._tag === "InvalidInputException" &&
418 (e.message ?? "").toLowerCase().includes("not authorized"),
419 schedule: Schedule.max([
420 Schedule.fixed("3 seconds"),
421 Schedule.recurs(10),
422 ]),
423 }),
424 );
425
426export const ProjectProvider = () =>
427 Provider.effect(
428 Project,
429 Effect.gen(function* () {
430 const toName = (id: string, props: Partial<ProjectProps>) =>
431 props.projectName
432 ? Effect.succeed(props.projectName)
433 : createPhysicalName({ id, maxLength: 128 });
434
435 /** Read a project by name; a missing project reads as absent. */
436 const getProject = Effect.fn(function* (name: string) {
437 const response = yield* codebuild.batchGetProjects({ names: [name] });
438 return response.projects?.[0];
439 });
440
441 return {
442 stables: ["projectName", "projectArn"],
443
444 diff: Effect.fn(function* ({ id, olds, news }) {
445 if (!isResolved(news)) return undefined;
446 if (
447 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
448 ) {
449 return { action: "replace" } as const;
450 }
451 }),
452
453 read: Effect.fn(function* ({ id, olds, output }) {
454 const name = output?.projectName ?? (yield* toName(id, olds ?? {}));
455 const project = yield* getProject(name);
456 if (project === undefined || project.arn === undefined) {
457 return undefined;
458 }
459 const attrs = {
460 projectName: project.name ?? name,
461 projectArn: project.arn,
462 };
463 const tags = toTagRecord(project.tags);
464 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
465 }),
466
467 reconcile: Effect.fn(function* ({ id, news, output, session }) {
468 const name = output?.projectName ?? (yield* toName(id, news));
469 const internalTags = yield* createInternalTags(id);
470 const desiredTags = { ...internalTags, ...news.tags };
471
472 // 1. Observe — cloud state is authoritative.
473 let observed = yield* getProject(name);
474
475 const spec = {
476 description: news.description,
477 source: toWireSource(news.source),
478 artifacts: toWireArtifacts(news.artifacts),
479 environment: toWireEnvironment(news.environment),
480 serviceRole: news.serviceRole,
481 // The CodeBuild wire unit for build/queue timeouts is whole minutes.
482 timeoutInMinutes: toWireMinutes(news.timeout),
483 queuedTimeoutInMinutes: toWireMinutes(news.queuedTimeout),
484 concurrentBuildLimit: news.concurrentBuildLimit,
485 encryptionKey: news.encryptionKey,
486 badgeEnabled: news.badgeEnabled,
487 logsConfig: news.logsConfig,
488 tags: toWireTags(desiredTags),
489 };
490
491 // 3. Sync — updateProject is a full upsert of the definition and
492 // tags; apply the desired spec (project updates are instant). A
493 // delete→redeploy race can leave batchGetProjects returning the
494 // just-deleted project; updateProject then reports the truth with
495 // a typed ResourceNotFoundException — treat it as missing.
496 if (observed !== undefined) {
497 observed = yield* retryIamPropagation(
498 codebuild.updateProject({ name, ...spec }),
499 ).pipe(
500 Effect.map((updated) => updated.project),
501 Effect.catchTag("ResourceNotFoundException", () =>
502 Effect.succeed(undefined),
503 ),
504 );
505 }
506
507 // 2. Ensure — create if missing; tolerate the create/create race.
508 if (observed === undefined) {
509 const created = yield* retryIamPropagation(
510 codebuild.createProject({ name, ...spec }),
511 ).pipe(
512 Effect.catchTag("ResourceAlreadyExistsException", () =>
513 getProject(name).pipe(Effect.map((p) => ({ project: p }))),
514 ),
515 );
516 observed = created.project;
517 }
518
519 if (observed === undefined || observed.arn === undefined) {
520 return yield* Effect.fail(
521 new Error(
522 `CodeBuild project '${name}' disappeared while reconciling`,
523 ),
524 );
525 }
526
527 // 3b. Sync — resource policy. Compare observed against desired in
528 // normalized form (keys sorted, no whitespace) so a re-deploy of an
529 // equivalent document — regardless of key order or string vs typed
530 // object — is a no-op that skips the API entirely.
531 const desiredPolicy =
532 news.resourcePolicy === undefined
533 ? undefined
534 : typeof news.resourcePolicy === "string"
535 ? news.resourcePolicy
536 : stringifyPolicyDocument(news.resourcePolicy);
537 const observedPolicy = yield* codebuild
538 .getResourcePolicy({ resourceArn: observed.arn })
539 .pipe(
540 Effect.map((res) => res.policy),
541 Effect.catchTag("ResourceNotFoundException", () =>
542 Effect.succeed(undefined),
543 ),
544 );
545 if (desiredPolicy === undefined) {
546 if (observedPolicy !== undefined && observedPolicy !== "") {
547 yield* codebuild.deleteResourcePolicy({
548 resourceArn: observed.arn,
549 });
550 }
551 } else if (
552 observedPolicy === undefined ||
553 normalizePolicyDocument(observedPolicy) !==
554 normalizePolicyDocument(desiredPolicy)
555 ) {
556 yield* codebuild.putResourcePolicy({
557 resourceArn: observed.arn,
558 policy: desiredPolicy,
559 });
560 }
561
562 // 4. Return fresh attributes.
563 yield* session.note(name);
564 return {
565 projectName: observed.name ?? name,
566 projectArn: observed.arn,
567 };
568 }),
569
570 delete: Effect.fn(function* ({ output }) {
571 // deleteProject is idempotent — a missing project returns success.
572 yield* codebuild.deleteProject({ name: output.projectName });
573 }),
574
575 list: () =>
576 codebuild.listProjects.pages({}).pipe(
577 Stream.runCollect,
578 Effect.map((chunk) =>
579 Array.from(chunk).flatMap((page) => page.projects ?? []),
580 ),
581 Effect.flatMap((names) =>
582 names.length === 0
583 ? Effect.succeed(
584 [] as { projectName: string; projectArn: string }[],
585 )
586 : Effect.forEach(
587 // batchGetProjects accepts up to 100 names per call.
588 chunkNames(names, 100),
589 (batch) =>
590 codebuild
591 .batchGetProjects({ names: batch })
592 .pipe(Effect.map((res) => res.projects ?? [])),
593 { concurrency: 2 },
594 ).pipe(
595 Effect.map((results) =>
596 results
597 .flat()
598 .flatMap((p) =>
599 p.name !== undefined && p.arn !== undefined
600 ? [{ projectName: p.name, projectArn: p.arn }]
601 : [],
602 ),
603 ),
604 ),
605 ),
606 ),
607 };
608 }),
609 );
610
611/** Split a list into fixed-size chunks. */
612const chunkNames = (names: string[], size: number): string[][] => {
613 const chunks: string[][] = [];
614 for (let i = 0; i < names.length; i += size) {
615 chunks.push(names.slice(i, i + size));
616 }
617 return chunks;
618};