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 DEFAULT_ADMIN,
19 DEFAULT_NETWORK,
20 domainNameOf,
21 fieldMask,
22 globalParent,
23 listDomains,
24 networkOf,
25 normalizeLocation,
26 parseName,
27 replaceOnIdentity,
28 ResourceNotResolved,
29 sameStringList,
30 toDomainName,
31 userLabels,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35 waitUntilReady,
36} from "./internal.ts";
37
38export type DomainProps = {
39 /**
40 * Fully-qualified domain name (the `{domain}` segment of
41 * `projects/{project}/locations/global/domains/{domain}`). Example:
42 * `corp.example.com`. If omitted, a unique FQDN is generated from the
43 * stack, stage, and logical id (`{id}.alch.test`). Must be 2-64
44 * characters, start with a letter, use only lowercase letters, numbers,
45 * periods, and hyphens, and the first label must be 15 characters or
46 * fewer. Immutable — changing it replaces the domain.
47 */
48 domainName?: string;
49 /**
50 * CIDR of internal addresses reserved for this domain (`10.0.1.0/24`).
51 * Must be `/24` or larger and must not overlap subnets in
52 * `authorizedNetworks`. Immutable — changing it replaces the domain.
53 */
54 reservedIpRange: string;
55 /**
56 * Regions where domain controllers are provisioned (`us-central1`,
57 * `us-east4`, …). Up to 4. `US-CENTRAL1` is accepted and normalized to
58 * `us-central1`.
59 * @default [the stack's GCP region] (`GCP.Region`, else the profile
60 * region, else `us-central1`)
61 */
62 locations?: string[];
63 /**
64 * VPC networks the domain is reachable from, as ids (`default`) or
65 * full names (`projects/{project}/global/networks/{network}`).
66 * @default ["default"]
67 */
68 authorizedNetworks?: string[];
69 /**
70 * Delegated administrator account used for Active Directory operations.
71 * Immutable — changing it replaces the domain.
72 * @default "setupadmin"
73 */
74 admin?: string;
75 /**
76 * Whether AD audit logs are enabled.
77 * @default false
78 */
79 auditLogsEnabled?: boolean;
80 /**
81 * User labels. Alchemy ownership labels are merged in automatically.
82 */
83 labels?: Record<string, string>;
84};
85
86export type Domain = Resource<
87 "GCP.ManagedIdentities.Domain",
88 DomainProps,
89 {
90 /** Full resource name `projects/{project}/locations/global/domains/{domain}`. */
91 name: string;
92 /** Fully-qualified domain name (last path segment). */
93 domainName: string;
94 /** Project id. */
95 project: string;
96 /** Resource location (`global`). */
97 location: string;
98 /** Reserved CIDR for the domain. */
99 reservedIpRange: string | undefined;
100 /** Regions where domain controllers are provisioned. */
101 locations: string[];
102 /** Authorized VPC networks. */
103 authorizedNetworks: string[];
104 /** Delegated administrator account. */
105 admin: string | undefined;
106 /** Whether AD audit logs are enabled. */
107 auditLogsEnabled: boolean;
108 /** User labels (Alchemy ownership labels stripped). */
109 labels: Record<string, string>;
110 /** Exposed Active Directory FQDN clients use to connect. */
111 fqdn: string | undefined;
112 /** Current trusts associated with the domain. */
113 trusts: managedidentities.Trust[];
114 /** Server-reported state (`READY`, `CREATING`, …). */
115 state: string | undefined;
116 /** Additional status information, if available. */
117 statusMessage: string | undefined;
118 /** RFC3339 creation timestamp. */
119 createTime: string | undefined;
120 /** RFC3339 last-update timestamp. */
121 updateTime: string | undefined;
122 },
123 never,
124 Providers
125>;
126
127/**
128 * A Managed Microsoft Active Directory domain.
129 *
130 * Changing `domainName`, `reservedIpRange`, or `admin` replaces the
131 * domain. `locations`, `authorizedNetworks`, `auditLogsEnabled`, and
132 * `labels` update in place. Provisioning typically takes 20-60 minutes.
133 *
134 * ### Creating a Domain
135 * **Example:** Generated FQDN
136 * ```typescript
137 * const domain = yield* GCP.ManagedIdentities.Domain("Corp", {
138 * reservedIpRange: "172.16.0.0/24",
139 * locations: ["us-central1"],
140 * });
141 * ```
142 *
143 * **Example:** Explicit FQDN, networks, and labels
144 * ```typescript
145 * const domain = yield* GCP.ManagedIdentities.Domain("Corp", {
146 * domainName: "corp.example.com",
147 * reservedIpRange: "172.16.0.0/24",
148 * locations: ["us-central1"],
149 * authorizedNetworks: ["default"],
150 * auditLogsEnabled: true,
151 * labels: { env: "prod" },
152 * });
153 * ```
154 *
155 * ### Updating a Domain
156 * Re-declare the same logical id with changed props; the engine keeps the
157 * physical resource and updates it in place.
158 *
159 * **Example:** Labels and audit logs
160 * ```typescript
161 * const domain = yield* GCP.ManagedIdentities.Domain("Corp", {
162 * domainName: "corp.example.com",
163 * reservedIpRange: "172.16.0.0/24",
164 * locations: ["us-central1"],
165 * auditLogsEnabled: true,
166 * labels: { env: "prod", team: "identity" },
167 * });
168 * ```
169 *
170 * @resource
171 * @category ManagedIdentities
172 */
173export const Domain = Resource<Domain>("GCP.ManagedIdentities.Domain");
174
175const resourceName = (project: string, domainName: string) =>
176 `${globalParent(project)}/domains/${domainName}`;
177
178const locationsOf = (
179 values: readonly string[] | undefined,
180 defaultLocation: string,
181) => (values ?? [defaultLocation]).map(normalizeLocation);
182
183const networksOf = (values: readonly string[] | undefined, project: string) =>
184 (values ?? [DEFAULT_NETWORK]).map((value) => networkOf(value, project));
185
186const adminOf = (value: string | undefined) => value || DEFAULT_ADMIN;
187
188const toAttrs = (item: managedidentities.Domain, project: string) => {
189 const name = item.name ?? "";
190 const parsed = parseName(name, "domains");
191 return {
192 name,
193 domainName: parsed.id,
194 project: parsed.project || project,
195 location: parsed.location,
196 reservedIpRange: item.reservedIpRange,
197 locations: item.locations ?? [],
198 authorizedNetworks: item.authorizedNetworks ?? [],
199 admin: item.admin,
200 auditLogsEnabled: item.auditLogsEnabled === true,
201 labels: userLabels(item.labels),
202 fqdn: item.fqdn,
203 trusts: item.trusts ?? [],
204 state: item.state,
205 statusMessage: item.statusMessage,
206 createTime: item.createTime,
207 updateTime: item.updateTime,
208 };
209};
210
211const getByName = (name: string) =>
212 name.length === 0
213 ? Effect.succeed(undefined)
214 : managedidentities
215 .getProjectsLocationsGlobalDomains({ name })
216 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
217
218export const DomainProvider = () =>
219 Provider.succeed(Domain, {
220 stables: ["name", "domainName", "project", "location", "createTime"],
221
222 diff: Effect.fn(function* ({ news, olds, output }) {
223 if (!isResolved(news)) return undefined;
224 const previousName = domainNameOf(
225 olds?.domainName ?? output?.domainName ?? output?.name ?? "",
226 );
227 const nextName = domainNameOf(
228 news.domainName ?? olds?.domainName ?? output?.domainName ?? "",
229 );
230 const previousRange = olds?.reservedIpRange ?? output?.reservedIpRange;
231 const previousAdmin = adminOf(olds?.admin ?? output?.admin);
232 return replaceOnIdentity({
233 previousId: previousName.length > 0 ? previousName : undefined,
234 nextId: nextName.length > 0 ? nextName : undefined,
235 extra:
236 (previousRange !== undefined &&
237 news.reservedIpRange !== previousRange) ||
238 (news.admin !== undefined && adminOf(news.admin) !== previousAdmin),
239 });
240 }),
241
242 read: Effect.fn(function* ({ id, olds, output }) {
243 const env = yield* GcpEnvironment.current;
244 const domainName = yield* toDomainName(
245 id,
246 olds?.domainName,
247 output?.domainName,
248 );
249 const name = output?.name ?? resourceName(env.project, domainName);
250 const existing = yield* getByName(name);
251 if (existing === undefined) return undefined;
252 const attrs = toAttrs(existing, env.project);
253 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
254 ? attrs
255 : Unowned(attrs);
256 }),
257
258 list: () =>
259 Effect.gen(function* () {
260 const env = yield* GcpEnvironment.current;
261 const items = yield* listDomains(env.project);
262 return items.map((item) => toAttrs(item, env.project));
263 }),
264
265 reconcile: Effect.fn(function* ({ id, news, output }) {
266 const env = yield* GcpEnvironment.current;
267 const domainName = yield* toDomainName(
268 id,
269 news.domainName,
270 output?.domainName,
271 );
272 const name = resourceName(env.project, domainName);
273 const desiredLabels = {
274 ...toLabels(news.labels),
275 ...(yield* createInternalLabels(id)),
276 };
277 const locations = locationsOf(
278 news.locations ?? output?.locations,
279 env.region,
280 );
281 const authorizedNetworks = networksOf(
282 news.authorizedNetworks ?? output?.authorizedNetworks,
283 env.project,
284 );
285 const auditLogsEnabled = news.auditLogsEnabled ?? false;
286
287 let current = yield* getByName(output?.name ?? name);
288
289 if (current === undefined) {
290 const created = yield* managedidentities
291 .createProjectsLocationsGlobalDomains({
292 parent: globalParent(env.project),
293 domainName,
294 body: {
295 reservedIpRange: news.reservedIpRange,
296 locations,
297 authorizedNetworks,
298 admin: news.admin,
299 auditLogsEnabled,
300 labels: desiredLabels,
301 },
302 })
303 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
304 if (created !== undefined) {
305 yield* waitForOperation(created);
306 }
307 current = yield* waitUntilExists(getByName(name), name);
308 }
309
310 if (current === undefined) {
311 return yield* new ResourceNotResolved({ name });
312 }
313
314 current = yield* waitUntilReady(
315 getByName(current.name ?? name),
316 current.name ?? name,
317 (item: managedidentities.Domain) => item.state,
318 (item: managedidentities.Domain) => item.statusMessage,
319 );
320
321 const observedLabels = tagRecord(current.labels);
322 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
323 const observedNetworks = current.authorizedNetworks ?? [];
324 const observedLocations = current.locations ?? [];
325 const mask = fieldMask([
326 (upsert.length > 0 || removed.length > 0) && "labels",
327 !sameStringList(observedLocations, locations) && "locations",
328 news.authorizedNetworks !== undefined &&
329 !sameStringList(observedNetworks, authorizedNetworks) &&
330 "authorized_networks",
331 (current.auditLogsEnabled === true) !== auditLogsEnabled &&
332 "audit_logs_enabled",
333 ]);
334
335 if (mask.length > 0) {
336 const operation =
337 yield* managedidentities.patchProjectsLocationsGlobalDomains({
338 name: current.name ?? name,
339 updateMask: mask,
340 body: {
341 name: current.name ?? name,
342 labels: desiredLabels,
343 locations,
344 authorizedNetworks,
345 auditLogsEnabled,
346 },
347 });
348 yield* waitForOperation(operation);
349 current = yield* waitUntilReady(
350 getByName(current.name ?? name),
351 current.name ?? name,
352 (item: managedidentities.Domain) => item.state,
353 (item: managedidentities.Domain) => item.statusMessage,
354 );
355 }
356
357 return toAttrs(current, env.project);
358 }),
359
360 delete: Effect.fn(function* ({ output }) {
361 if (!output.name) return;
362 const operation = yield* managedidentities
363 .deleteProjectsLocationsGlobalDomains({ name: output.name })
364 .pipe(
365 Effect.retry({
366 while: (error) => error._tag === "Conflict",
367 times: 8,
368 schedule: Schedule.spaced("5 seconds"),
369 }),
370 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
371 );
372 if (operation !== undefined) {
373 yield* waitForOperation(operation, { notFoundOk: true });
374 }
375 yield* waitUntilGone(getByName(output.name), output.name);
376 }),
377 });