1import * as codepipeline from "@distilled.cloud/aws/codepipeline";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13
14/**
15 * Where a pipeline stores the artifacts it passes between stages.
16 */
17export interface PipelineArtifactStore {
18 /**
19 * Store type. Only `S3` is supported by CodePipeline.
20 * @default "S3"
21 */
22 type?: "S3";
23 /**
24 * Name of the S3 bucket that holds pipeline artifacts.
25 */
26 location: string;
27 /**
28 * Optional customer-managed KMS key for artifact encryption.
29 */
30 encryptionKey?: {
31 /** Key id or ARN. */
32 id: string;
33 /** Key type. */
34 type: "KMS";
35 };
36}
37
38/**
39 * A single action within a stage — a source pull, a build, a deploy, etc.
40 */
41export interface PipelineActionConfig {
42 /** Unique name of the action within its stage. */
43 name: string;
44 /** Action category. */
45 category:
46 | "Source"
47 | "Build"
48 | "Deploy"
49 | "Test"
50 | "Invoke"
51 | "Approval"
52 | "Compute";
53 /** Who owns the action provider. */
54 owner: "AWS" | "ThirdParty" | "Custom";
55 /**
56 * Action provider, e.g. `S3`, `CodeBuild`, `CodeDeploy`,
57 * `CodeStarSourceConnection`.
58 */
59 provider: string;
60 /**
61 * Provider version.
62 * @default "1"
63 */
64 version?: string;
65 /**
66 * Provider-specific configuration key/value map (e.g. `{ ProjectName }`
67 * for CodeBuild, `{ S3Bucket, S3ObjectKey }` for an S3 source).
68 */
69 configuration?: Record<string, string>;
70 /** Names of artifacts this action consumes. */
71 inputArtifacts?: string[];
72 /** Names of artifacts this action produces. */
73 outputArtifacts?: string[];
74 /** Order of execution within the stage (actions with the same order run in parallel). */
75 runOrder?: number;
76 /** Region the action runs in (for cross-region actions). */
77 region?: string;
78 /** IAM role the action assumes. */
79 roleArn?: string;
80 /** Variable namespace for referencing this action's output variables. */
81 namespace?: string;
82}
83
84/**
85 * A pipeline stage: a named group of actions.
86 */
87export interface PipelineStageConfig {
88 /** Unique name of the stage. */
89 name: string;
90 /** Actions that make up the stage. */
91 actions: PipelineActionConfig[];
92}
93
94export interface PipelineProps {
95 /**
96 * Name of the pipeline. If omitted a deterministic physical name is
97 * generated. Changing the name replaces the pipeline.
98 */
99 pipelineName?: string;
100 /**
101 * ARN of the IAM role CodePipeline assumes. Must trust
102 * `codepipeline.amazonaws.com`.
103 */
104 roleArn: string;
105 /**
106 * Artifact store for passing outputs between stages.
107 */
108 artifactStore: PipelineArtifactStore;
109 /**
110 * Ordered list of stages. A pipeline needs at least two stages, the first
111 * of which contains a single source action.
112 */
113 stages: PipelineStageConfig[];
114 /**
115 * Pipeline type.
116 * @default "V2"
117 */
118 pipelineType?: "V1" | "V2";
119 /**
120 * Execution mode (V2 pipelines).
121 * @default "SUPERSEDED"
122 */
123 executionMode?: "QUEUED" | "SUPERSEDED" | "PARALLEL";
124 /**
125 * User-defined tags.
126 */
127 tags?: Record<string, string>;
128}
129
130export interface Pipeline extends Resource<
131 "AWS.CodePipeline.Pipeline",
132 PipelineProps,
133 {
134 /** Physical name of the pipeline. */
135 pipelineName: string;
136 /** ARN of the pipeline. */
137 pipelineArn: string;
138 /** Version number, incremented by CodePipeline on every structure update. */
139 pipelineVersion: number;
140 },
141 never,
142 Providers
143> {}
144
145/**
146 * An AWS CodePipeline continuous-delivery pipeline: an ordered set of
147 * stages, each running one or more actions (source → build → deploy), with
148 * an S3 artifact store carrying outputs between them.
149 *
150 * Defining and updating the pipeline is instant. Pipelines that use a git
151 * source require a CodeConnections connection whose OAuth handshake is
152 * completed manually — use an S3 source to avoid the handshake.
153 * ### Creating a Pipeline
154 * **Example:** S3-Source → CodeBuild Pipeline
155 * ```typescript
156 * const pipeline = yield* CodePipeline.Pipeline("Release", {
157 * roleArn: role.roleArn,
158 * artifactStore: { type: "S3", location: artifactBucket.bucketName },
159 * stages: [
160 * {
161 * name: "Source",
162 * actions: [{
163 * name: "S3Source",
164 * category: "Source",
165 * owner: "AWS",
166 * provider: "S3",
167 * outputArtifacts: ["SourceOutput"],
168 * configuration: {
169 * S3Bucket: sourceBucket.bucketName,
170 * S3ObjectKey: "source.zip",
171 * PollForSourceChanges: "false",
172 * },
173 * }],
174 * },
175 * {
176 * name: "Build",
177 * actions: [{
178 * name: "Build",
179 * category: "Build",
180 * owner: "AWS",
181 * provider: "CodeBuild",
182 * inputArtifacts: ["SourceOutput"],
183 * configuration: { ProjectName: project.projectName },
184 * }],
185 * },
186 * ],
187 * });
188 * ```
189 *
190 * @resource
191 */
192export const Pipeline = Resource<Pipeline>("AWS.CodePipeline.Pipeline");
193
194/** Convert a CodePipeline wire tag list into a plain record. */
195const toTagRecord = (
196 tags: ReadonlyArray<{ key?: string; value?: string }> | undefined,
197): Record<string, string> =>
198 Object.fromEntries(
199 (tags ?? [])
200 .filter(
201 (tag): tag is { key: string; value: string } =>
202 typeof tag.key === "string" && typeof tag.value === "string",
203 )
204 .map((tag) => [tag.key, tag.value]),
205 );
206
207const toWireArtifactStore = (
208 store: PipelineArtifactStore,
209): codepipeline.ArtifactStore => ({
210 type: store.type ?? "S3",
211 location: store.location,
212 encryptionKey: store.encryptionKey,
213});
214
215const toWireStages = (
216 stages: PipelineStageConfig[],
217): codepipeline.StageDeclaration[] =>
218 stages.map((stage) => ({
219 name: stage.name,
220 actions: stage.actions.map((action) => ({
221 name: action.name,
222 actionTypeId: {
223 category: action.category,
224 owner: action.owner,
225 provider: action.provider,
226 version: action.version ?? "1",
227 },
228 runOrder: action.runOrder,
229 configuration: action.configuration,
230 inputArtifacts: action.inputArtifacts?.map((n) => ({ name: n })),
231 outputArtifacts: action.outputArtifacts?.map((n) => ({ name: n })),
232 region: action.region,
233 roleArn: action.roleArn,
234 namespace: action.namespace,
235 })),
236 }));
237
238const toWireDeclaration = (
239 name: string,
240 props: PipelineProps,
241): codepipeline.PipelineDeclaration => ({
242 name,
243 roleArn: props.roleArn,
244 artifactStore: toWireArtifactStore(props.artifactStore),
245 stages: toWireStages(props.stages),
246 pipelineType: props.pipelineType ?? "V2",
247 executionMode: props.executionMode,
248});
249
250/**
251 * CodePipeline validates the pipeline role at create time; a freshly created
252 * IAM role is not yet assumable, surfacing as an `InvalidStructureException`
253 * whose message mentions the role cannot be assumed / is not authorized.
254 * Retry (bounded) through IAM propagation. The explicit return annotation
255 * keeps the retry's conditional type out of declaration emit (PATTERNS §7).
256 */
257const retryIamPropagation = <
258 A,
259 E extends { readonly _tag: string; readonly message?: string },
260 R,
261>(
262 effect: Effect.Effect<A, E, R>,
263): Effect.Effect<A, E, R> =>
264 effect.pipe(
265 Effect.retry({
266 while: (e) => {
267 const message = (e.message ?? "").toLowerCase();
268 return (
269 e._tag === "InvalidStructureException" &&
270 (message.includes("not authorized") ||
271 message.includes("cannot be assumed") ||
272 message.includes("unable to assume"))
273 );
274 },
275 schedule: Schedule.max([
276 Schedule.fixed("3 seconds"),
277 Schedule.recurs(10),
278 ]),
279 }),
280 );
281
282export const PipelineProvider = () =>
283 Provider.effect(
284 Pipeline,
285 Effect.gen(function* () {
286 const toName = (id: string, props: Partial<PipelineProps>) =>
287 props.pipelineName
288 ? Effect.succeed(props.pipelineName)
289 : createPhysicalName({ id, maxLength: 100 });
290
291 /** Read a pipeline; a missing pipeline reads as absent. */
292 const getPipeline = Effect.fn(function* (name: string) {
293 return yield* codepipeline
294 .getPipeline({ name })
295 .pipe(
296 Effect.catchTag("PipelineNotFoundException", () =>
297 Effect.succeed(undefined),
298 ),
299 );
300 });
301
302 const syncTags = Effect.fn(function* (
303 arn: string,
304 desiredTags: Record<string, string>,
305 ) {
306 const observed = yield* codepipeline
307 .listTagsForResource({ resourceArn: arn })
308 .pipe(Effect.catch(() => Effect.succeed(undefined)));
309 const { removed, upsert } = diffTags(
310 toTagRecord(observed?.tags),
311 desiredTags,
312 );
313 if (upsert.length > 0) {
314 yield* codepipeline.tagResource({
315 resourceArn: arn,
316 tags: upsert.map(({ Key, Value }) => ({ key: Key, value: Value })),
317 });
318 }
319 if (removed.length > 0) {
320 yield* codepipeline.untagResource({
321 resourceArn: arn,
322 tagKeys: removed,
323 });
324 }
325 });
326
327 return {
328 stables: ["pipelineName", "pipelineArn"],
329
330 diff: Effect.fn(function* ({ id, olds, news }) {
331 if (!isResolved(news)) return undefined;
332 if (
333 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
334 ) {
335 return { action: "replace" } as const;
336 }
337 }),
338
339 read: Effect.fn(function* ({ id, olds, output }) {
340 const name = output?.pipelineName ?? (yield* toName(id, olds ?? {}));
341 const found = yield* getPipeline(name);
342 if (found?.metadata?.pipelineArn === undefined) return undefined;
343 const attrs = {
344 pipelineName: found.pipeline?.name ?? name,
345 pipelineArn: found.metadata.pipelineArn,
346 pipelineVersion: found.pipeline?.version ?? 1,
347 };
348 const tags = yield* codepipeline
349 .listTagsForResource({ resourceArn: attrs.pipelineArn })
350 .pipe(
351 Effect.map((res) => toTagRecord(res.tags)),
352 Effect.catch(() => Effect.succeed({})),
353 );
354 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
355 }),
356
357 reconcile: Effect.fn(function* ({ id, news, output, session }) {
358 const { accountId, region } = yield* AWSEnvironment.current;
359 const name = output?.pipelineName ?? (yield* toName(id, news));
360 const internalTags = yield* createInternalTags(id);
361 const desiredTags = { ...internalTags, ...news.tags };
362 const declaration = toWireDeclaration(name, news);
363
364 // 1. Observe — cloud state is authoritative.
365 let observed = yield* getPipeline(name);
366
367 // 2. Ensure — create if missing; tolerate the name-in-use race.
368 if (observed === undefined) {
369 yield* retryIamPropagation(
370 codepipeline.createPipeline({
371 pipeline: declaration,
372 tags: Object.entries(desiredTags).map(([key, value]) => ({
373 key,
374 value,
375 })),
376 }),
377 ).pipe(
378 Effect.catchTag("PipelineNameInUseException", () => Effect.void),
379 );
380 observed = yield* getPipeline(name);
381 } else {
382 // 3. Sync — updatePipeline is a full upsert of the declaration.
383 yield* retryIamPropagation(
384 codepipeline.updatePipeline({ pipeline: declaration }),
385 );
386 observed = yield* getPipeline(name);
387 }
388
389 const arn =
390 observed?.metadata?.pipelineArn ??
391 `arn:aws:codepipeline:${region}:${accountId}:${name}`;
392
393 // 3b. Sync tags — diff against OBSERVED cloud tags.
394 yield* syncTags(arn, desiredTags);
395
396 // 4. Return fresh attributes.
397 yield* session.note(name);
398 return {
399 pipelineName: observed?.pipeline?.name ?? name,
400 pipelineArn: arn,
401 pipelineVersion: observed?.pipeline?.version ?? 1,
402 };
403 }),
404
405 delete: Effect.fn(function* ({ output }) {
406 // deletePipeline is idempotent — a missing pipeline returns success.
407 yield* codepipeline.deletePipeline({ name: output.pipelineName });
408 }),
409
410 list: () =>
411 codepipeline.listPipelines.pages({}).pipe(
412 Stream.runCollect,
413 Effect.flatMap((chunk) =>
414 Effect.gen(function* () {
415 const { accountId, region } = yield* AWSEnvironment.current;
416 return Array.from(chunk)
417 .flatMap((page) => page.pipelines ?? [])
418 .flatMap((p) =>
419 p.name !== undefined
420 ? [
421 {
422 pipelineName: p.name,
423 pipelineArn: `arn:aws:codepipeline:${region}:${accountId}:${p.name}`,
424 pipelineVersion: p.version ?? 1,
425 },
426 ]
427 : [],
428 );
429 }),
430 ),
431 ),
432 };
433 }),
434 );