1import * as codeconnections from "@distilled.cloud/aws/codeconnections";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import { fetchObservedTags, syncResourceTags, toTagList } from "./internal.ts";
12
13export interface ConnectionProps {
14 /**
15 * Name of the connection (1-32 chars). If omitted a deterministic
16 * physical name is generated. Changing the name replaces the connection.
17 */
18 connectionName?: string;
19 /**
20 * Source-provider the connection authenticates against. Changing the
21 * provider replaces the connection.
22 */
23 providerType:
24 | "GitHub"
25 | "GitHubEnterpriseServer"
26 | "Bitbucket"
27 | "GitLab"
28 | "GitLabSelfManaged";
29 /**
30 * ARN of a CodeConnections `Host` (required for self-managed providers
31 * such as `GitHubEnterpriseServer` / `GitLabSelfManaged`).
32 */
33 hostArn?: string;
34 /**
35 * User-defined tags.
36 */
37 tags?: Record<string, string>;
38}
39
40export interface Connection extends Resource<
41 "AWS.CodeConnections.Connection",
42 ConnectionProps,
43 {
44 /** Physical name of the connection. */
45 connectionName: string;
46 /** ARN of the connection (referenced by CodePipeline source actions). */
47 connectionArn: string;
48 /**
49 * Connection state. A freshly created connection is `PENDING` until the
50 * OAuth handshake is completed manually in the AWS console; it then
51 * becomes `AVAILABLE`.
52 */
53 connectionStatus: string;
54 /** The source provider (`GitHub`, `GitLab`, `Bitbucket`, ...). */
55 providerType: string;
56 },
57 never,
58 Providers
59> {}
60
61/**
62 * An AWS CodeConnections connection to a source-code provider (GitHub,
63 * GitLab, Bitbucket).
64 *
65 * A connection is created in the `PENDING` state. Completing it requires a
66 * one-time OAuth handshake performed **manually** in the AWS console (the
67 * "Update pending connection" flow) — there is no API to finish the
68 * handshake. Once completed the connection becomes `AVAILABLE` and can be
69 * referenced by a CodePipeline `CodeStarSourceConnection` action.
70 * ### Creating a Connection
71 * **Example:** GitHub Connection (created PENDING)
72 * ```typescript
73 * const connection = yield* CodeConnections.Connection("GitHub", {
74 * providerType: "GitHub",
75 * });
76 * // connection.connectionStatus === "PENDING"
77 * // Complete the handshake in the console before using it in a pipeline.
78 * ```
79 *
80 * @resource
81 */
82export const Connection = Resource<Connection>(
83 "AWS.CodeConnections.Connection",
84);
85
86export const ConnectionProvider = () =>
87 Provider.effect(
88 Connection,
89 Effect.gen(function* () {
90 const toName = (id: string, props: Partial<ConnectionProps>) =>
91 props.connectionName
92 ? Effect.succeed(props.connectionName)
93 : createPhysicalName({ id, maxLength: 32 });
94
95 /** Read a connection by ARN; a missing connection reads as absent. */
96 const getByArn = Effect.fn(function* (arn: string) {
97 const response = yield* codeconnections
98 .getConnection({ ConnectionArn: arn })
99 .pipe(
100 Effect.catchTag("ResourceNotFoundException", () =>
101 Effect.succeed(undefined),
102 ),
103 );
104 return response?.Connection;
105 });
106
107 /** Find a connection by name (getConnection only accepts an ARN). */
108 const findByName = Effect.fn(function* (name: string) {
109 const connections = yield* codeconnections.listConnections
110 .pages({})
111 .pipe(
112 Stream.runCollect,
113 Effect.map((chunk) =>
114 Array.from(chunk).flatMap((page) => page.Connections ?? []),
115 ),
116 );
117 return connections.find((c) => c.ConnectionName === name);
118 });
119
120 const toAttrs = (
121 connection: codeconnections.Connection,
122 name: string,
123 ) => ({
124 connectionName: connection.ConnectionName ?? name,
125 connectionArn: connection.ConnectionArn!,
126 connectionStatus: connection.ConnectionStatus ?? "PENDING",
127 providerType: connection.ProviderType ?? "",
128 });
129
130 return {
131 stables: ["connectionName", "connectionArn", "providerType"],
132
133 diff: Effect.fn(function* ({ id, olds, news }) {
134 if (!isResolved(news)) return undefined;
135 if (
136 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
137 ) {
138 return { action: "replace" } as const;
139 }
140 // Provider type and host are immutable — replace on change.
141 if (
142 (news?.providerType ?? undefined) !==
143 (olds?.providerType ?? undefined) ||
144 (news?.hostArn ?? undefined) !== (olds?.hostArn ?? undefined)
145 ) {
146 return { action: "replace" } as const;
147 }
148 }),
149
150 read: Effect.fn(function* ({ id, olds, output }) {
151 const name =
152 output?.connectionName ?? (yield* toName(id, olds ?? {}));
153 const connection = output?.connectionArn
154 ? yield* getByArn(output.connectionArn)
155 : yield* findByName(name);
156 if (connection?.ConnectionArn === undefined) return undefined;
157 const attrs = toAttrs(connection, name);
158 const tags = yield* fetchObservedTags(attrs.connectionArn);
159 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
160 }),
161
162 reconcile: Effect.fn(function* ({ id, news, output, session }) {
163 const name = output?.connectionName ?? (yield* toName(id, news));
164 const internalTags = yield* createInternalTags(id);
165 const desiredTags = { ...internalTags, ...news.tags };
166
167 // 1. Observe — cloud state is authoritative.
168 let observed = output?.connectionArn
169 ? yield* getByArn(output.connectionArn)
170 : yield* findByName(name);
171
172 // 2. Ensure — connections are immutable; create if missing. The
173 // connection is created in PENDING and its OAuth handshake is
174 // completed manually.
175 if (observed?.ConnectionArn === undefined) {
176 const created = yield* codeconnections.createConnection({
177 ConnectionName: name,
178 ProviderType: news.providerType,
179 HostArn: news.hostArn,
180 Tags: toTagList(desiredTags),
181 });
182 observed = yield* getByArn(created.ConnectionArn);
183 if (observed?.ConnectionArn === undefined) {
184 observed = {
185 ConnectionName: name,
186 ConnectionArn: created.ConnectionArn,
187 ProviderType: news.providerType,
188 ConnectionStatus: "PENDING",
189 };
190 }
191 }
192
193 // 3. Sync tags — diff against OBSERVED cloud tags.
194 yield* syncResourceTags(observed.ConnectionArn!, desiredTags);
195
196 // 4. Return fresh attributes.
197 yield* session.note(name);
198 return toAttrs(observed, name);
199 }),
200
201 delete: Effect.fn(function* ({ output }) {
202 yield* codeconnections
203 .deleteConnection({ ConnectionArn: output.connectionArn })
204 .pipe(
205 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
206 );
207 }),
208
209 list: () =>
210 codeconnections.listConnections.pages({}).pipe(
211 Stream.runCollect,
212 Effect.map((chunk) =>
213 Array.from(chunk)
214 .flatMap((page) => page.Connections ?? [])
215 .flatMap((c) =>
216 c.ConnectionArn !== undefined
217 ? [
218 {
219 connectionName: c.ConnectionName ?? "",
220 connectionArn: c.ConnectionArn,
221 connectionStatus: c.ConnectionStatus ?? "PENDING",
222 providerType: c.ProviderType ?? "",
223 },
224 ]
225 : [],
226 ),
227 ),
228 ),
229 };
230 }),
231 );