1import * as codeconnections from "@distilled.cloud/aws/codeconnections";
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, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import { fetchObservedTags, syncResourceTags, toTagList } from "./internal.ts";
13
14/**
15 * VPC configuration provisioned for a {@link Host} whose self-managed
16 * provider is only reachable from inside a VPC.
17 */
18export interface HostVpcConfiguration {
19 /**
20 * ID of the VPC connected to the provider's infrastructure.
21 */
22 vpcId: string;
23 /**
24 * IDs of the subnets associated with the VPC.
25 */
26 subnetIds: string[];
27 /**
28 * IDs of the security groups associated with the VPC.
29 */
30 securityGroupIds: string[];
31 /**
32 * PEM value of the TLS certificate used by the provider's infrastructure,
33 * for endpoints served with a self-signed or private CA certificate.
34 * This is public certificate material (not a private key).
35 */
36 tlsCertificate?: string;
37}
38
39export interface HostProps {
40 /**
41 * Name of the host (1-64 chars). If omitted a deterministic physical name
42 * is generated. Changing the name replaces the host.
43 */
44 name?: string;
45 /**
46 * The self-managed source provider the host is installed on. Changing the
47 * provider replaces the host.
48 */
49 providerType: "GitHubEnterpriseServer" | "GitLabSelfManaged";
50 /**
51 * Endpoint of the infrastructure the provider is installed on, e.g.
52 * `https://ghe.example.com`.
53 */
54 providerEndpoint: string;
55 /**
56 * VPC configuration to provision for the host when the provider endpoint
57 * is only reachable from inside a VPC.
58 */
59 vpcConfiguration?: HostVpcConfiguration;
60 /**
61 * User-defined tags.
62 */
63 tags?: Record<string, string>;
64}
65
66export interface Host extends Resource<
67 "AWS.CodeConnections.Host",
68 HostProps,
69 {
70 /** Physical name of the host. */
71 hostName: string;
72 /** ARN of the host (passed as `hostArn` to a `Connection`). */
73 hostArn: string;
74 /**
75 * Host state. A freshly created host is `PENDING` until its setup is
76 * completed **manually** in the AWS console; it then becomes
77 * `AVAILABLE`.
78 */
79 hostStatus: string;
80 /** The self-managed source provider. */
81 providerType: string;
82 /** Endpoint of the provider's infrastructure. */
83 providerEndpoint: string;
84 },
85 never,
86 Providers
87> {}
88
89/**
90 * An AWS CodeConnections host — the infrastructure representation of a
91 * self-managed source provider (GitHub Enterprise Server or GitLab
92 * self-managed). One host serves all connections to that provider.
93 *
94 * A host is created in the `PENDING` state. Completing it requires a
95 * one-time setup performed **manually** in the AWS console — there is no
96 * API to finish the setup. Once completed the host becomes `AVAILABLE` and
97 * `Connection`s can reference it via `hostArn`.
98 * ### Creating a Host
99 * **Example:** GitHub Enterprise Server Host (created PENDING)
100 * ```typescript
101 * const host = yield* CodeConnections.Host("GHE", {
102 * providerType: "GitHubEnterpriseServer",
103 * providerEndpoint: "https://ghe.example.com",
104 * });
105 * // host.hostStatus === "PENDING"
106 * // Complete the setup in the console before creating connections on it.
107 * ```
108 *
109 * **Example:** Connection on a Host
110 * ```typescript
111 * const connection = yield* CodeConnections.Connection("GHEConn", {
112 * providerType: "GitHubEnterpriseServer",
113 * hostArn: host.hostArn,
114 * });
115 * ```
116 *
117 * ### Reaching a Private Endpoint
118 * **Example:** Host with VPC Configuration
119 * ```typescript
120 * const host = yield* CodeConnections.Host("PrivateGHE", {
121 * providerType: "GitHubEnterpriseServer",
122 * providerEndpoint: "https://ghe.internal.example.com",
123 * vpcConfiguration: {
124 * vpcId: vpc.vpcId,
125 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
126 * securityGroupIds: [securityGroup.securityGroupId],
127 * },
128 * });
129 * ```
130 *
131 * @resource
132 */
133export const Host = Resource<Host>("AWS.CodeConnections.Host");
134
135const toWireVpcConfiguration = (
136 vpc: HostVpcConfiguration | undefined,
137): codeconnections.VpcConfiguration | undefined =>
138 vpc === undefined
139 ? undefined
140 : {
141 VpcId: vpc.vpcId,
142 SubnetIds: vpc.subnetIds,
143 SecurityGroupIds: vpc.securityGroupIds,
144 TlsCertificate: vpc.tlsCertificate,
145 };
146
147const sameVpcConfiguration = (
148 observed: codeconnections.VpcConfiguration | undefined,
149 desired: codeconnections.VpcConfiguration | undefined,
150): boolean =>
151 observed?.VpcId === desired?.VpcId &&
152 JSON.stringify([...(observed?.SubnetIds ?? [])].sort()) ===
153 JSON.stringify([...(desired?.SubnetIds ?? [])].sort()) &&
154 JSON.stringify([...(observed?.SecurityGroupIds ?? [])].sort()) ===
155 JSON.stringify([...(desired?.SecurityGroupIds ?? [])].sort()) &&
156 (observed?.TlsCertificate ?? undefined) ===
157 (desired?.TlsCertificate ?? undefined);
158
159export const HostProvider = () =>
160 Provider.effect(
161 Host,
162 Effect.gen(function* () {
163 const toName = (id: string, props: Partial<HostProps>) =>
164 props.name
165 ? Effect.succeed(props.name)
166 : createPhysicalName({ id, maxLength: 64 });
167
168 /**
169 * Read a host by ARN; a missing host reads as absent. `GetHost` does
170 * not echo the ARN back, so it is re-attached from the input.
171 */
172 const getByArn = Effect.fn(function* (arn: string) {
173 const response = yield* codeconnections
174 .getHost({ HostArn: arn })
175 .pipe(
176 Effect.catchTag("ResourceNotFoundException", () =>
177 Effect.succeed(undefined),
178 ),
179 );
180 return response === undefined
181 ? undefined
182 : ({ ...response, HostArn: arn } satisfies codeconnections.Host);
183 });
184
185 /** Find a host by name (getHost only accepts an ARN). */
186 const findByName = Effect.fn(function* (name: string) {
187 const hosts = yield* codeconnections.listHosts.pages({}).pipe(
188 Stream.runCollect,
189 Effect.map((chunk) =>
190 Array.from(chunk).flatMap((page) => page.Hosts ?? []),
191 ),
192 );
193 return hosts.find((h) => h.Name === name);
194 });
195
196 const toAttrs = (host: codeconnections.Host, name: string) => ({
197 hostName: host.Name ?? name,
198 hostArn: host.HostArn!,
199 hostStatus: host.Status ?? "PENDING",
200 providerType: host.ProviderType ?? "",
201 providerEndpoint: host.ProviderEndpoint ?? "",
202 });
203
204 return {
205 stables: ["hostName", "hostArn", "providerType"],
206
207 diff: Effect.fn(function* ({ id, olds, news }) {
208 if (!isResolved(news)) return undefined;
209 if (
210 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
211 ) {
212 return { action: "replace" } as const;
213 }
214 // Provider type is immutable — replace on change. Endpoint and VPC
215 // configuration are mutable via UpdateHost.
216 if (
217 (news?.providerType ?? undefined) !==
218 (olds?.providerType ?? undefined)
219 ) {
220 return { action: "replace" } as const;
221 }
222 }),
223
224 read: Effect.fn(function* ({ id, olds, output }) {
225 const name = output?.hostName ?? (yield* toName(id, olds ?? {}));
226 const host = output?.hostArn
227 ? yield* getByArn(output.hostArn)
228 : yield* findByName(name);
229 if (host?.HostArn === undefined) return undefined;
230 const attrs = toAttrs(host, name);
231 const tags = yield* fetchObservedTags(attrs.hostArn);
232 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
233 }),
234
235 reconcile: Effect.fn(function* ({ id, news, output, session }) {
236 const name = output?.hostName ?? (yield* toName(id, news));
237 const internalTags = yield* createInternalTags(id);
238 const desiredTags = { ...internalTags, ...news.tags };
239 const desiredVpc = toWireVpcConfiguration(news.vpcConfiguration);
240
241 // 1. Observe — cloud state is authoritative.
242 let observed = output?.hostArn
243 ? yield* getByArn(output.hostArn)
244 : yield* findByName(name);
245
246 // 2. Ensure — create if missing. The host is created PENDING and
247 // its setup is completed manually in the console.
248 if (observed?.HostArn === undefined) {
249 const created = yield* codeconnections.createHost({
250 Name: name,
251 ProviderType: news.providerType,
252 ProviderEndpoint: news.providerEndpoint,
253 VpcConfiguration: desiredVpc,
254 Tags: toTagList(desiredTags),
255 });
256 observed = created.HostArn
257 ? yield* getByArn(created.HostArn)
258 : yield* findByName(name);
259 if (observed?.HostArn === undefined) {
260 observed = {
261 Name: name,
262 HostArn: created.HostArn,
263 ProviderType: news.providerType,
264 ProviderEndpoint: news.providerEndpoint,
265 VpcConfiguration: desiredVpc,
266 Status: "PENDING",
267 };
268 }
269 }
270
271 // 3. Sync — endpoint + VPC configuration, diffed against OBSERVED
272 // cloud state; skip the API entirely on no-op.
273 if (
274 observed.ProviderEndpoint !== news.providerEndpoint ||
275 !sameVpcConfiguration(observed.VpcConfiguration, desiredVpc)
276 ) {
277 yield* codeconnections.updateHost({
278 HostArn: observed.HostArn!,
279 ProviderEndpoint: news.providerEndpoint,
280 VpcConfiguration: desiredVpc,
281 });
282 observed = (yield* getByArn(observed.HostArn!)) ?? observed;
283 }
284
285 // 4. Sync tags — diff against OBSERVED cloud tags.
286 yield* syncResourceTags(observed.HostArn!, desiredTags);
287
288 yield* session.note(name);
289 return toAttrs(observed, name);
290 }),
291
292 delete: Effect.fn(function* ({ output }) {
293 // A host cannot be deleted while its VPC configuration is
294 // initializing/deleting — retry the transient state, bounded.
295 yield* codeconnections.deleteHost({ HostArn: output.hostArn }).pipe(
296 Effect.retry({
297 while: (e): boolean => e._tag === "ResourceUnavailableException",
298 schedule: Schedule.exponential("2 seconds"),
299 times: 8,
300 }),
301 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
302 );
303 }),
304
305 list: () =>
306 codeconnections.listHosts.pages({}).pipe(
307 Stream.runCollect,
308 Effect.map((chunk) =>
309 Array.from(chunk)
310 .flatMap((page) => page.Hosts ?? [])
311 .flatMap((h) =>
312 h.HostArn !== undefined
313 ? [
314 {
315 hostName: h.Name ?? "",
316 hostArn: h.HostArn,
317 hostStatus: h.Status ?? "PENDING",
318 providerType: h.ProviderType ?? "",
319 providerEndpoint: h.ProviderEndpoint ?? "",
320 },
321 ]
322 : [],
323 ),
324 ),
325 ),
326 };
327 }),
328 );