1import * as backupdr from "@distilled.cloud/gcp/backupdr_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 { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 fingerprint,
14 listAtLocation,
15 listLabeledPages,
16 networkName,
17 normalizeLocation,
18 parentOf,
19 parseName,
20 replaceOnIdentity,
21 ResourceNotResolved,
22 toPhysicalId,
23 userLabels,
24 waitForOperation,
25 waitUntilExists,
26 waitUntilGone,
27 waitUntilReady,
28} from "./internal.ts";
29
30export type NetworkConfig = {
31 /**
32 * VPC network the management server is connected to. Full name
33 * `projects/{project}/global/networks/{network}` or a network id.
34 */
35 network?: string;
36 /**
37 * Peering mode. Only `PRIVATE_SERVICE_ACCESS` is supported.
38 */
39 peeringMode?: backupdr.NetworkConfigPeeringModeEnum | (string & {});
40};
41
42export type ManagementURI = {
43 /** AGM/RD Web UI URL. */
44 webUi?: string;
45 /** AGM/RD API URL. */
46 api?: string;
47};
48
49export type WorkforceIdentityBasedManagementURI = {
50 /** Third-party management URI. */
51 thirdPartyManagementUri?: string;
52 /** First-party management URI. */
53 firstPartyManagementUri?: string;
54};
55
56export type WorkforceIdentityBasedOAuth2ClientID = {
57 /** First-party OAuth client id. */
58 firstPartyOauth2ClientId?: string;
59 /** Third-party OAuth client id. */
60 thirdPartyOauth2ClientId?: string;
61};
62
63export type ManagementServerProps = {
64 /**
65 * Management server id (the `{managementServer}` segment of
66 * `projects/{project}/locations/{location}/managementServers/{managementServer}`).
67 * If omitted, a unique RFC1035 name is generated from the stack, stage,
68 * and logical id. Immutable — changing it replaces the server.
69 */
70 managementServerId?: string;
71 /**
72 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
73 * replaces the server. `US-CENTRAL1` is accepted and normalized to
74 * `us-central1`.
75 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
76 */
77 location?: string;
78 /**
79 * Instance type. Immutable — changing it replaces the server.
80 * @default "BACKUP_RESTORE"
81 */
82 type?: backupdr.ManagementServerTypeEnum | (string & {});
83 /**
84 * VPC networks the server is connected to. Only a single network is
85 * supported. Optional when created without Private Service Access.
86 * Immutable — changing it replaces the server.
87 */
88 networks?: NetworkConfig[];
89 /**
90 * Human-readable description (2048 characters or less). Set at create;
91 * the API has no patch method.
92 */
93 description?: string;
94 /**
95 * User labels. Alchemy ownership labels are merged in automatically.
96 * Set at create; the API has no patch method.
97 */
98 labels?: Record<string, string>;
99};
100
101export type ManagementServer = Resource<
102 "GCP.BackupDR.ManagementServer",
103 ManagementServerProps,
104 {
105 /** Full resource name. */
106 name: string;
107 /** Management server id (last path segment). */
108 managementServerId: string;
109 /** Project id. */
110 project: string;
111 /** Location id. */
112 location: string;
113 /** Instance type. */
114 type: string | undefined;
115 /** Connected VPC networks. */
116 networks: NetworkConfig[];
117 /** Human-readable description. */
118 description: string | undefined;
119 /** User labels (Alchemy ownership labels stripped). */
120 labels: Record<string, string>;
121 /** OAuth 2.0 client id for the management API. */
122 oauth2ClientId: string | undefined;
123 /** Management URIs. */
124 managementUri: ManagementURI | undefined;
125 /** Workforce-identity management URIs. */
126 workforceIdentityBasedManagementUri:
127 | WorkforceIdentityBasedManagementURI
128 | undefined;
129 /** Workforce-identity OAuth client ids. */
130 workforceIdentityBasedOauth2ClientId:
131 | WorkforceIdentityBasedOAuth2ClientID
132 | undefined;
133 /** BA proxy URIs. */
134 baProxyUri: string[];
135 /** Server-reported state. */
136 state: string | undefined;
137 /** Assured Workloads PZI. */
138 satisfiesPzi: boolean | undefined;
139 /** Assured Workloads PZS. */
140 satisfiesPzs: boolean | undefined;
141 /** RFC3339 creation timestamp. */
142 createTime: string | undefined;
143 /** RFC3339 last-update timestamp. */
144 updateTime: string | undefined;
145 },
146 never,
147 Providers
148>;
149
150/**
151 * A Backup and DR management console (ManagementServer). Provisioning is
152 * slow (tens of minutes) and typically limited to one console per region.
153 *
154 * There is no patch API — labels, description, type, and networks are
155 * set at create. Changing `managementServerId`, `location`, `type`, or
156 * `networks` replaces the server.
157 *
158 * ### Creating a Management Server
159 * **Example:** Generated name, default network
160 * ```typescript
161 * const server = yield* GCP.BackupDR.ManagementServer("Console", {
162 * networks: [{ network: "default", peeringMode: "PRIVATE_SERVICE_ACCESS" }],
163 * });
164 * ```
165 *
166 * **Example:** Explicit id
167 * ```typescript
168 * const server = yield* GCP.BackupDR.ManagementServer("Console", {
169 * managementServerId: "backup-console",
170 * type: "BACKUP_RESTORE",
171 * description: "primary management console",
172 * labels: { env: "prod" },
173 * });
174 * ```
175 *
176 * @resource
177 * @category BackupDR
178 */
179export const ManagementServer = Resource<ManagementServer>(
180 "GCP.BackupDR.ManagementServer",
181);
182
183const resourceName = (
184 project: string,
185 location: string,
186 managementServerId: string,
187) =>
188 `projects/${project}/locations/${location}/managementServers/${managementServerId}`;
189
190const toNetworks = (
191 project: string,
192 networks: readonly NetworkConfig[] | undefined,
193): NetworkConfig[] | undefined => {
194 if (networks === undefined) return undefined;
195 return networks.map((network) => ({
196 network: networkName(project, network.network),
197 peeringMode: network.peeringMode,
198 }));
199};
200
201const fromNetworks = (
202 networks: readonly backupdr.NetworkConfig[] | undefined,
203): NetworkConfig[] =>
204 (networks ?? []).map((network) => ({
205 network: network.network,
206 peeringMode: network.peeringMode,
207 }));
208
209const toAttrs = (
210 item: backupdr.ManagementServer,
211 project: string,
212 region: string,
213) => {
214 const name = item.name ?? "";
215 const parsed = parseName(name, "managementServers", region);
216 return {
217 name,
218 managementServerId: parsed.id,
219 project: parsed.project || project,
220 location: parsed.location,
221 type: item.type,
222 networks: fromNetworks(item.networks),
223 description: item.description,
224 labels: userLabels(item.labels),
225 oauth2ClientId: item.oauth2ClientId,
226 managementUri: item.managementUri,
227 workforceIdentityBasedManagementUri:
228 item.workforceIdentityBasedManagementUri,
229 workforceIdentityBasedOauth2ClientId:
230 item.workforceIdentityBasedOauth2ClientId,
231 baProxyUri: item.baProxyUri ?? [],
232 state: item.state,
233 satisfiesPzi: item.satisfiesPzi,
234 satisfiesPzs: item.satisfiesPzs,
235 createTime: item.createTime,
236 updateTime: item.updateTime,
237 };
238};
239
240const getByName = (name: string) =>
241 backupdr
242 .getProjectsLocationsManagementServers({ name })
243 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
244
245const listOwned = (project: string, region: string) =>
246 listAtLocation(project, region, (parent) =>
247 listLabeledPages(
248 backupdr.listProjectsLocationsManagementServers.pages({
249 parent,
250 pageSize: 1000,
251 }),
252 (page) => page.managementServers,
253 (item) => item.labels,
254 ),
255 );
256
257export const ManagementServerProvider = () =>
258 Provider.succeed(ManagementServer, {
259 stables: [
260 "name",
261 "managementServerId",
262 "project",
263 "location",
264 "createTime",
265 ],
266
267 diff: Effect.fn(function* ({ news, olds, output }) {
268 if (!isResolved(news)) return undefined;
269 const env = yield* GcpEnvironment.current;
270 const previousType = olds?.type ?? output?.type;
271 const previousNetworks = fingerprint(olds?.networks ?? output?.networks);
272 const nextNetworks =
273 news.networks === undefined
274 ? previousNetworks
275 : fingerprint(news.networks);
276 return replaceOnIdentity({
277 previousId: olds?.managementServerId ?? output?.managementServerId,
278 nextId:
279 news.managementServerId ??
280 olds?.managementServerId ??
281 output?.managementServerId,
282 previousLocation: normalizeLocation(
283 olds?.location ?? output?.location,
284 env.region,
285 ),
286 nextLocation: normalizeLocation(
287 news.location ?? olds?.location ?? output?.location,
288 env.region,
289 ),
290 extra:
291 (previousType !== undefined &&
292 news.type !== undefined &&
293 previousType !== news.type) ||
294 previousNetworks !== nextNetworks,
295 });
296 }),
297
298 read: Effect.fn(function* ({ id, olds, output }) {
299 const env = yield* GcpEnvironment.current;
300 const managementServerId = yield* toPhysicalId(
301 id,
302 olds?.managementServerId,
303 output?.managementServerId,
304 "mgmtserver",
305 );
306 const location = normalizeLocation(
307 olds?.location ?? output?.location,
308 env.region,
309 );
310 const name =
311 output?.name ?? resourceName(env.project, location, managementServerId);
312 const existing = yield* getByName(name);
313 if (existing === undefined) return undefined;
314 const attrs = toAttrs(existing, env.project, env.region);
315 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
316 ? attrs
317 : Unowned(attrs);
318 }),
319
320 list: () =>
321 Effect.gen(function* () {
322 const env = yield* GcpEnvironment.current;
323 const items = yield* listOwned(env.project, env.region);
324 return items.map((item) => toAttrs(item, env.project, env.region));
325 }),
326
327 reconcile: Effect.fn(function* ({ id, news, output }) {
328 const env = yield* GcpEnvironment.current;
329 const managementServerId = yield* toPhysicalId(
330 id,
331 news.managementServerId,
332 output?.managementServerId,
333 "mgmtserver",
334 );
335 const location = normalizeLocation(
336 news.location ?? output?.location,
337 env.region,
338 );
339 const name = resourceName(env.project, location, managementServerId);
340 const desiredLabels = {
341 ...toLabels(news.labels),
342 ...(yield* createInternalLabels(id)),
343 };
344 const networks = toNetworks(env.project, news.networks);
345
346 let current = yield* getByName(output?.name ?? name);
347
348 if (current === undefined) {
349 const created = yield* backupdr
350 .createProjectsLocationsManagementServers({
351 parent: parentOf(env.project, location),
352 managementServerId,
353 body: {
354 type: news.type,
355 networks,
356 description: news.description,
357 labels: desiredLabels,
358 },
359 })
360 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
361 if (created !== undefined) {
362 // ALREADY_EXISTS (6): a concurrent create won the race.
363 yield* waitForOperation(created).pipe(
364 Effect.catchTag("GCP.OperationFailed", (error) =>
365 error.code === 6 ? Effect.void : Effect.fail(error),
366 ),
367 );
368 }
369 current = yield* waitUntilExists(getByName(name), name);
370 }
371
372 if (current === undefined) {
373 return yield* new ResourceNotResolved({ name });
374 }
375
376 current = yield* waitUntilReady(
377 getByName(current.name ?? name),
378 current.name ?? name,
379 (item) => item.state,
380 );
381
382 return toAttrs(current, env.project, env.region);
383 }),
384
385 delete: Effect.fn(function* ({ output }) {
386 const operation = yield* backupdr
387 .deleteProjectsLocationsManagementServers({ name: output.name })
388 .pipe(
389 Effect.retry({
390 while: (error) => error._tag === "Conflict",
391 times: 8,
392 schedule: Schedule.spaced("2 seconds"),
393 }),
394 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
395 );
396 if (operation !== undefined) {
397 // NOT_FOUND (5): already gone.
398 yield* waitForOperation(operation).pipe(
399 Effect.catchTag("GCP.OperationFailed", (error) =>
400 error.code === 5 ? Effect.void : Effect.fail(error),
401 ),
402 );
403 }
404 yield* waitUntilGone(getByName(output.name), output.name);
405 }),
406 });