1import * as managedidentities from "@distilled.cloud/gcp/managedidentities_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 domainResourceOf,
19 fieldMask,
20 globalParent,
21 listPeerings,
22 networkOf,
23 parseName,
24 replaceOnIdentity,
25 ResourceNotResolved,
26 toPhysicalId,
27 userLabels,
28 waitForOperation,
29 waitUntilExists,
30 waitUntilGone,
31 waitUntilReady,
32} from "./internal.ts";
33
34export type PeeringProps = {
35 /**
36 * Peering id (the `{peering}` segment of
37 * `projects/{project}/locations/global/peerings/{peering}`). If omitted,
38 * a unique RFC1035 name is generated from the stack, stage, and logical
39 * id. Must match `^[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?$`. Immutable —
40 * changing it replaces the peering.
41 */
42 peeringId?: string;
43 /**
44 * Managed Microsoft AD domain to peer with. Must live in a different
45 * project than `authorizedNetwork`. Full name
46 * `projects/{project}/locations/global/domains/{domain}` or the domain
47 * FQDN. Immutable — changing it replaces the peering.
48 */
49 domainResource: string;
50 /**
51 * VPC network the domain is peered into, as an id (`default`) or full
52 * name (`projects/{project}/global/networks/{network}`). Caller must
53 * ensure CIDR subnets do not overlap the domain. Immutable — changing
54 * it replaces the peering.
55 */
56 authorizedNetwork: string;
57 /**
58 * User labels. Alchemy ownership labels are merged in automatically.
59 * Labels are the only mutable field.
60 */
61 labels?: Record<string, string>;
62};
63
64export type Peering = Resource<
65 "GCP.ManagedIdentities.Peering",
66 PeeringProps,
67 {
68 /** Full resource name `projects/{project}/locations/global/peerings/{peering}`. */
69 name: string;
70 /** Peering id (last path segment). */
71 peeringId: string;
72 /** Project id. */
73 project: string;
74 /** Resource location (`global`). */
75 location: string;
76 /** Peered domain resource name. */
77 domainResource: string | undefined;
78 /** Authorized VPC network. */
79 authorizedNetwork: string | undefined;
80 /** User labels (Alchemy ownership labels stripped). */
81 labels: Record<string, string>;
82 /** Server-reported state (`CONNECTED`, `CREATING`, …). */
83 state: string | undefined;
84 /** Additional status information, if available. */
85 statusMessage: string | undefined;
86 /** RFC3339 creation timestamp. */
87 createTime: string | undefined;
88 /** RFC3339 last-update timestamp. */
89 updateTime: string | undefined;
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A Managed Microsoft AD domain peering — attaches a VPC in this
97 * project to a Managed AD domain that lives in a **different** project
98 * so VMs on that network can join the domain. Same-project access uses
99 * `Domain.authorizedNetworks` instead; creating a peering when domain
100 * and network share a project is rejected with `BadRequest`.
101 *
102 * Changing `peeringId`, `domainResource`, or `authorizedNetwork`
103 * replaces the peering. Labels update in place.
104 *
105 * ### Creating a Peering
106 * **Example:** Generated name
107 * ```typescript
108 * const peering = yield* GCP.ManagedIdentities.Peering("Spoke", {
109 * domainResource: domain.name,
110 * authorizedNetwork: "default",
111 * });
112 * ```
113 *
114 * **Example:** Explicit id and labels
115 * ```typescript
116 * const peering = yield* GCP.ManagedIdentities.Peering("Spoke", {
117 * peeringId: "app-spoke",
118 * domainResource: domain.name,
119 * authorizedNetwork: "projects/my-project/global/networks/default",
120 * labels: { env: "prod" },
121 * });
122 * ```
123 *
124 * ### Updating a Peering
125 * Re-declare the same logical id with changed props; the engine keeps the
126 * physical resource and updates it in place.
127 *
128 * **Example:** Labels
129 * ```typescript
130 * const peering = yield* GCP.ManagedIdentities.Peering("Spoke", {
131 * domainResource: domain.name,
132 * authorizedNetwork: "default",
133 * labels: { env: "prod", team: "identity" },
134 * });
135 * ```
136 *
137 * @resource
138 * @category ManagedIdentities
139 */
140export const Peering = Resource<Peering>("GCP.ManagedIdentities.Peering");
141
142const resourceName = (project: string, peeringId: string) =>
143 `${globalParent(project)}/peerings/${peeringId}`;
144
145const toAttrs = (item: managedidentities.Peering, project: string) => {
146 const name = item.name ?? "";
147 const parsed = parseName(name, "peerings");
148 return {
149 name,
150 peeringId: parsed.id,
151 project: parsed.project || project,
152 location: parsed.location,
153 domainResource: item.domainResource,
154 authorizedNetwork: item.authorizedNetwork,
155 labels: userLabels(item.labels),
156 state: item.state,
157 statusMessage: item.statusMessage,
158 createTime: item.createTime,
159 updateTime: item.updateTime,
160 };
161};
162
163const getByName = (name: string) =>
164 name.length === 0
165 ? Effect.succeed(undefined)
166 : managedidentities
167 .getProjectsLocationsGlobalPeerings({ name })
168 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
169
170export const PeeringProvider = () =>
171 Provider.succeed(Peering, {
172 stables: ["name", "peeringId", "project", "location", "createTime"],
173
174 diff: Effect.fn(function* ({ news, olds, output }) {
175 if (!isResolved(news)) return undefined;
176 const env = yield* GcpEnvironment.current;
177 const previousDomain = domainResourceOf(
178 olds?.domainResource ?? output?.domainResource ?? "",
179 env.project,
180 );
181 const nextDomain = domainResourceOf(
182 news.domainResource ??
183 olds?.domainResource ??
184 output?.domainResource ??
185 "",
186 env.project,
187 );
188 const previousNetwork = networkOf(
189 olds?.authorizedNetwork ?? output?.authorizedNetwork ?? "",
190 env.project,
191 );
192 const nextNetwork = networkOf(
193 news.authorizedNetwork ??
194 olds?.authorizedNetwork ??
195 output?.authorizedNetwork ??
196 "",
197 env.project,
198 );
199 return replaceOnIdentity({
200 previousId: olds?.peeringId ?? output?.peeringId,
201 nextId: news.peeringId ?? olds?.peeringId ?? output?.peeringId,
202 extra:
203 (previousDomain.length > 0 &&
204 nextDomain.length > 0 &&
205 previousDomain !== nextDomain) ||
206 (previousNetwork.length > 0 &&
207 nextNetwork.length > 0 &&
208 previousNetwork !== nextNetwork),
209 });
210 }),
211
212 read: Effect.fn(function* ({ id, olds, output }) {
213 const env = yield* GcpEnvironment.current;
214 const peeringId = yield* toPhysicalId(
215 id,
216 olds?.peeringId,
217 output?.peeringId,
218 "peering",
219 );
220 const name = output?.name ?? resourceName(env.project, peeringId);
221 const existing = yield* getByName(name);
222 if (existing === undefined) return undefined;
223 const attrs = toAttrs(existing, env.project);
224 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
225 ? attrs
226 : Unowned(attrs);
227 }),
228
229 list: () =>
230 Effect.gen(function* () {
231 const env = yield* GcpEnvironment.current;
232 const items = yield* listPeerings(env.project);
233 return items.map((item) => toAttrs(item, env.project));
234 }),
235
236 reconcile: Effect.fn(function* ({ id, news, output }) {
237 const env = yield* GcpEnvironment.current;
238 const peeringId = yield* toPhysicalId(
239 id,
240 news.peeringId,
241 output?.peeringId,
242 "peering",
243 );
244 const name = resourceName(env.project, peeringId);
245 const domainResource = domainResourceOf(news.domainResource, env.project);
246 const authorizedNetwork = networkOf(news.authorizedNetwork, env.project);
247 const desiredLabels = {
248 ...toLabels(news.labels),
249 ...(yield* createInternalLabels(id)),
250 };
251
252 let current = yield* getByName(output?.name ?? name);
253
254 if (current === undefined) {
255 const created = yield* managedidentities
256 .createProjectsLocationsGlobalPeerings({
257 parent: globalParent(env.project),
258 peeringId,
259 body: {
260 domainResource,
261 authorizedNetwork,
262 labels: desiredLabels,
263 },
264 })
265 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
266 if (created !== undefined) {
267 yield* waitForOperation(created);
268 }
269 current = yield* waitUntilExists(getByName(name), name);
270 }
271
272 if (current === undefined) {
273 return yield* new ResourceNotResolved({ name });
274 }
275
276 current = yield* waitUntilReady(
277 getByName(current.name ?? name),
278 current.name ?? name,
279 (item: managedidentities.Peering) => item.state,
280 (item: managedidentities.Peering) => item.statusMessage,
281 );
282
283 const observedLabels = tagRecord(current.labels);
284 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
285 const mask = fieldMask([
286 (upsert.length > 0 || removed.length > 0) && "labels",
287 ]);
288
289 if (mask.length > 0) {
290 const operation =
291 yield* managedidentities.patchProjectsLocationsGlobalPeerings({
292 name: current.name ?? name,
293 updateMask: mask,
294 body: {
295 name: current.name ?? name,
296 labels: desiredLabels,
297 },
298 });
299 yield* waitForOperation(operation);
300 current = yield* waitUntilReady(
301 getByName(current.name ?? name),
302 current.name ?? name,
303 (item: managedidentities.Peering) => item.state,
304 (item: managedidentities.Peering) => item.statusMessage,
305 );
306 }
307
308 return toAttrs(current, env.project);
309 }),
310
311 delete: Effect.fn(function* ({ output }) {
312 if (!output.name) return;
313 const operation = yield* managedidentities
314 .deleteProjectsLocationsGlobalPeerings({ name: output.name })
315 .pipe(
316 Effect.retry({
317 while: (error) => error._tag === "Conflict",
318 times: 8,
319 schedule: Schedule.spaced("2 seconds"),
320 }),
321 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
322 );
323 if (operation !== undefined) {
324 yield* waitForOperation(operation, { notFoundOk: true });
325 }
326 yield* waitUntilGone(getByName(output.name), output.name);
327 }),
328 });