1import * as networkconnectivity from "@distilled.cloud/gcp/networkconnectivity_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 NetworkConnectivityNotResolved,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 rfc1035,
26 sameJson,
27 toPhysicalId,
28 userLabels,
29 waitForOperation,
30 waitUntilGone,
31 waitUntilPresent,
32} from "./internal.ts";
33
34const COLLECTION = "multicloudDataTransferConfigs";
35
36export type StateMetadata = {
37 /** Transient or rest state (`ADDING`, `ACTIVE`, `DELETING`, …). */
38 state?: string;
39 /** RFC3339 time when a transient state becomes effective. */
40 effectiveTime?: string;
41};
42
43export type StateTimeline = {
44 /** Ordered state/activation entries. */
45 states?: StateMetadata[];
46};
47
48export type MulticloudDataTransferConfigProps = {
49 /**
50 * Config id (the `{multicloud_data_transfer_config}` segment of
51 * `projects/{project}/locations/{location}/multicloudDataTransferConfigs/{multicloud_data_transfer_config}`).
52 * If omitted, a unique name is generated from the stack, stage, and
53 * logical id. Immutable — changing it replaces the config.
54 */
55 multicloudDataTransferConfigId?: string;
56 /**
57 * Location (`us-central1`, …). Immutable — changing it replaces the
58 * config. `US-CENTRAL1` is accepted and normalized to `us-central1`.
59 * @default the stack's GCP region (`GCP.Region`, else the profile
60 * region, else `us-central1`)
61 */
62 location?: string;
63 /**
64 * Human-readable description.
65 */
66 description?: string;
67 /**
68 * Map of Data Transfer Essentials service names to their desired
69 * timeline. Keys are service ids such as `"google-cloud-storage"`.
70 */
71 services?: Record<string, StateTimeline>;
72 /**
73 * User labels. Alchemy ownership labels are merged in automatically.
74 */
75 labels?: Record<string, string>;
76};
77
78export type MulticloudDataTransferConfig = Resource<
79 "GCP.NetworkConnectivity.MulticloudDataTransferConfig",
80 MulticloudDataTransferConfigProps,
81 {
82 /** Full resource name `projects/{project}/locations/{location}/multicloudDataTransferConfigs/{id}`. */
83 name: string;
84 /** Config id (last path segment). */
85 multicloudDataTransferConfigId: string;
86 /** Project id. */
87 project: string;
88 /** Location id. */
89 location: string;
90 /** User-provided description. */
91 description: string | undefined;
92 /** Service timelines currently configured. */
93 services: Record<string, StateTimeline>;
94 /** Number of Destination resources configured. */
95 destinationsCount: number | undefined;
96 /** Number of Destination resources in use. */
97 destinationsActiveCount: number | undefined;
98 /** User labels (Alchemy ownership labels stripped). */
99 labels: Record<string, string>;
100 /** Google-generated unique id. */
101 uid: string | undefined;
102 /** Server etag. */
103 etag: string | undefined;
104 /** RFC3339 creation timestamp. */
105 createTime: string | undefined;
106 /** RFC3339 last-update timestamp. */
107 updateTime: string | undefined;
108 },
109 never,
110 Providers
111>;
112
113/**
114 * A Data Transfer Essentials `MulticloudDataTransferConfig` — the
115 * billing/metering configuration for services whose traffic is billed
116 * through DTE.
117 *
118 * Changing `multicloudDataTransferConfigId` or `location` replaces the
119 * config. Description, labels, and `services` update in place.
120 *
121 * ### Creating a Config
122 * **Example:** Generated name
123 * ```typescript
124 * const config = yield* GCP.NetworkConnectivity.MulticloudDataTransferConfig(
125 * "Dte",
126 * { description: "dte metering", labels: { env: "prod" } },
127 * );
128 * ```
129 *
130 * **Example:** Named config with a service
131 * ```typescript
132 * const config = yield* GCP.NetworkConnectivity.MulticloudDataTransferConfig(
133 * "Dte",
134 * {
135 * multicloudDataTransferConfigId: "app-dte",
136 * location: "us-central1",
137 * services: { "google-cloud-storage": {} },
138 * },
139 * );
140 * ```
141 *
142 * ### Updating a Config
143 * **Example:** Description and labels
144 * ```typescript
145 * const config = yield* GCP.NetworkConnectivity.MulticloudDataTransferConfig(
146 * "Dte",
147 * {
148 * multicloudDataTransferConfigId: "app-dte",
149 * location: "us-central1",
150 * description: "dte metering v2",
151 * labels: { env: "prod", role: "dte" },
152 * },
153 * );
154 * ```
155 *
156 * @resource
157 * @category NetworkConnectivity
158 */
159export const MulticloudDataTransferConfig =
160 Resource<MulticloudDataTransferConfig>(
161 "GCP.NetworkConnectivity.MulticloudDataTransferConfig",
162 );
163
164const resourceName = (project: string, location: string, configId: string) =>
165 `projects/${project}/locations/${location}/multicloudDataTransferConfigs/${configId}`;
166
167const toTimeline = (
168 timeline: StateTimeline | networkconnectivity.StateTimeline | undefined,
169): StateTimeline => ({
170 states: (timeline?.states ?? []).map((entry) => ({
171 state: entry.state,
172 effectiveTime: entry.effectiveTime,
173 })),
174});
175
176const toServices = (
177 services:
178 | Record<string, StateTimeline | undefined>
179 | networkconnectivity.StateTimelineMap
180 | undefined,
181): Record<string, StateTimeline> => {
182 const result: Record<string, StateTimeline> = {};
183 for (const [key, value] of Object.entries(services ?? {})) {
184 if (value !== undefined) {
185 result[key] = toTimeline(value);
186 }
187 }
188 return result;
189};
190
191const toAttrs = (
192 config: networkconnectivity.MulticloudDataTransferConfig,
193 project: string,
194 region: string,
195) => {
196 const name = config.name ?? "";
197 const parsed = parseName(name, COLLECTION, region);
198 return {
199 name,
200 multicloudDataTransferConfigId: parsed.id,
201 project: parsed.project || project,
202 location: parsed.location || region,
203 description: config.description,
204 services: toServices(config.services),
205 destinationsCount: config.destinationsCount,
206 destinationsActiveCount: config.destinationsActiveCount,
207 labels: userLabels(config.labels),
208 uid: config.uid,
209 etag: config.etag,
210 createTime: config.createTime,
211 updateTime: config.updateTime,
212 };
213};
214
215const getByName = (name: string) =>
216 networkconnectivity
217 .getProjectsLocationsMulticloudDataTransferConfigs({ name })
218 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
219
220export const MulticloudDataTransferConfigProvider = () =>
221 Provider.succeed(MulticloudDataTransferConfig, {
222 stables: [
223 "name",
224 "multicloudDataTransferConfigId",
225 "project",
226 "location",
227 "uid",
228 "createTime",
229 ],
230
231 diff: Effect.fn(function* ({ news, olds, output }) {
232 if (!isResolved(news)) return undefined;
233 const env = yield* GcpEnvironment.current;
234 const previousId =
235 olds?.multicloudDataTransferConfigId ??
236 output?.multicloudDataTransferConfigId;
237 const nextId = news.multicloudDataTransferConfigId
238 ? rfc1035(
239 news.multicloudDataTransferConfigId,
240 "multicloud-data-transfer-config",
241 )
242 : previousId;
243 const previousLocation = normalizeLocation(
244 olds?.location ?? output?.location,
245 env.region,
246 );
247 const nextLocation = normalizeLocation(
248 news.location ?? olds?.location ?? output?.location,
249 env.region,
250 );
251 if (
252 (previousId !== undefined &&
253 nextId !== undefined &&
254 nextId !== previousId) ||
255 previousLocation !== nextLocation
256 ) {
257 return { action: "replace" as const };
258 }
259 return undefined;
260 }),
261
262 read: Effect.fn(function* ({ id, olds, output }) {
263 const env = yield* GcpEnvironment.current;
264 const configId = yield* toPhysicalId(
265 id,
266 olds?.multicloudDataTransferConfigId,
267 output?.multicloudDataTransferConfigId,
268 "multicloud-data-transfer-config",
269 );
270 const location = normalizeLocation(
271 olds?.location ?? output?.location,
272 env.region,
273 );
274 const name =
275 output?.name ?? resourceName(env.project, location, configId);
276 const existing = yield* getByName(name);
277 if (existing === undefined) return undefined;
278 const attrs = toAttrs(existing, env.project, env.region);
279 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
280 ? attrs
281 : Unowned(attrs);
282 }),
283
284 list: () =>
285 Effect.gen(function* () {
286 const env = yield* GcpEnvironment.current;
287 const items = yield* collectPages(
288 networkconnectivity.listProjectsLocationsMulticloudDataTransferConfigs.pages(
289 {
290 parent: parentOf(env.project, "-"),
291 pageSize: 1000,
292 returnPartialSuccess: true,
293 },
294 ),
295 (page) => page.multicloudDataTransferConfigs,
296 );
297 return items
298 .filter((item) => hasAlchemyLabelKeys(item.labels))
299 .map((item) => toAttrs(item, env.project, env.region));
300 }),
301
302 reconcile: Effect.fn(function* ({ id, news, output }) {
303 const env = yield* GcpEnvironment.current;
304 const configId = yield* toPhysicalId(
305 id,
306 news.multicloudDataTransferConfigId,
307 output?.multicloudDataTransferConfigId,
308 "multicloud-data-transfer-config",
309 );
310 const location = normalizeLocation(
311 news.location ?? output?.location,
312 env.region,
313 );
314 const name = resourceName(env.project, location, configId);
315 const desiredLabels = {
316 ...toLabels(news.labels),
317 ...(yield* createInternalLabels(id)),
318 };
319 const desiredServices = toServices(news.services);
320
321 let current = yield* getByName(name);
322
323 if (current === undefined) {
324 const created = yield* networkconnectivity
325 .createProjectsLocationsMulticloudDataTransferConfigs({
326 parent: parentOf(env.project, location),
327 multicloudDataTransferConfigId: configId,
328 body: {
329 description: news.description,
330 labels: desiredLabels,
331 services: desiredServices,
332 },
333 })
334 .pipe(
335 Effect.retry({
336 while: (error) => error._tag === "Conflict",
337 times: 5,
338 schedule: Schedule.spaced("2 seconds"),
339 }),
340 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
341 );
342 if (created !== undefined) {
343 yield* waitForOperation(created);
344 }
345 current = yield* waitUntilPresent(getByName(name), name);
346 }
347
348 if (current === undefined) {
349 return yield* new NetworkConnectivityNotResolved({ name });
350 }
351
352 const observedLabels = tagRecord(current.labels);
353 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
354 const labelsChanged = upsert.length > 0 || removed.length > 0;
355 const descriptionChanged =
356 (current.description ?? "") !== (news.description ?? "");
357 const servicesChanged = !sameJson(
358 toServices(current.services),
359 desiredServices,
360 );
361 const updateMask = changedFields([
362 ["labels", labelsChanged],
363 ["description", descriptionChanged],
364 ["services", servicesChanged],
365 ]);
366
367 if (updateMask.length > 0) {
368 const operation =
369 yield* networkconnectivity.patchProjectsLocationsMulticloudDataTransferConfigs(
370 {
371 name: current.name ?? name,
372 updateMask: updateMask.join(","),
373 body: {
374 name: current.name ?? name,
375 labels: desiredLabels,
376 description: news.description,
377 services: desiredServices,
378 etag: current.etag,
379 },
380 },
381 );
382 yield* waitForOperation(operation);
383 current = yield* waitUntilPresent(
384 getByName(current.name ?? name),
385 current.name ?? name,
386 );
387 }
388
389 return toAttrs(current, env.project, env.region);
390 }),
391
392 delete: Effect.fn(function* ({ output }) {
393 const operation = yield* networkconnectivity
394 .deleteProjectsLocationsMulticloudDataTransferConfigs({
395 name: output.name,
396 etag: output.etag,
397 })
398 .pipe(
399 Effect.retry({
400 while: (error) => error._tag === "Conflict",
401 times: 8,
402 schedule: Schedule.spaced("2 seconds"),
403 }),
404 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
405 );
406 if (operation !== undefined) {
407 yield* waitForOperation(operation, { notFoundOk: true });
408 }
409 yield* waitUntilGone(getByName(output.name), output.name);
410 }),
411 });