1import * as networkmanagement from "@distilled.cloud/gcp/networkmanagement_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_GLOBAL,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 listOrganizations,
23 normalizeLocation,
24 organizationParent,
25 organizationResourceName,
26 parseName,
27 rfc1035,
28 sameStringList,
29 toNetworkResource,
30 toPhysicalId,
31 toRegionalComputeResource,
32 toSubnetworkResource,
33 userLabels,
34 waitForOperation,
35 waitUntilGone,
36 waitUntilPresent,
37 resolveOrganization,
38} from "./internal.ts";
39
40const COLLECTION = "vpcFlowLogsConfigs";
41const DEFAULT_STATE =
42 "ENABLED" satisfies networkmanagement.VpcFlowLogsConfigStateEnum;
43const DEFAULT_INTERVAL =
44 "INTERVAL_5_SEC" satisfies networkmanagement.VpcFlowLogsConfigAggregationIntervalEnum;
45const DEFAULT_METADATA =
46 "INCLUDE_ALL_METADATA" satisfies networkmanagement.VpcFlowLogsConfigMetadataEnum;
47const DEFAULT_CROSS_PROJECT =
48 "CROSS_PROJECT_METADATA_ENABLED" satisfies networkmanagement.VpcFlowLogsConfigCrossProjectMetadataEnum;
49const DEFAULT_SAMPLING = 1;
50
51export type OrganizationsVpcFlowLogsConfigState =
52 | networkmanagement.VpcFlowLogsConfigStateEnum
53 | (string & {});
54export type OrganizationsVpcFlowLogsConfigAggregationInterval =
55 | networkmanagement.VpcFlowLogsConfigAggregationIntervalEnum
56 | (string & {});
57export type OrganizationsVpcFlowLogsConfigMetadata =
58 | networkmanagement.VpcFlowLogsConfigMetadataEnum
59 | (string & {});
60export type OrganizationsVpcFlowLogsConfigCrossProjectMetadata =
61 | networkmanagement.VpcFlowLogsConfigCrossProjectMetadataEnum
62 | (string & {});
63
64export type OrganizationsVpcFlowLogsConfigProps = {
65 /**
66 * Config id (the `{vpc_flow_logs_config}` segment of
67 * `organizations/{organization}/locations/global/vpcFlowLogsConfigs/{vpc_flow_logs_config}`).
68 * If omitted, a unique RFC1035 name is generated. Immutable — changing
69 * it replaces the config.
70 */
71 vpcFlowLogsConfigId?: string;
72 /**
73 * Organization id or `organizations/{organization}`. If omitted,
74 * Alchemy uses `GOOGLE_ORGANIZATION_ID` or the project's Resource
75 * Manager parent. Immutable — changing it replaces the config.
76 */
77 organization?: string;
78 /**
79 * Location. Must be `global`. Immutable — changing it replaces the
80 * config.
81 * @default "global"
82 */
83 location?: string;
84 /**
85 * Human-readable description (max 512 characters).
86 */
87 description?: string;
88 /**
89 * Whether log generation is enabled. Create requires `ENABLED`; a
90 * subsequent update may set `DISABLED` to pause generation.
91 * @default "ENABLED"
92 */
93 state?: OrganizationsVpcFlowLogsConfigState;
94 /**
95 * Log aggregation interval.
96 * @default "INTERVAL_5_SEC"
97 */
98 aggregationInterval?: OrganizationsVpcFlowLogsConfigAggregationInterval;
99 /**
100 * Sampling rate in (0, 1]. Use `state` to disable logs rather than
101 * setting this to 0.
102 * @default 1
103 */
104 flowSampling?: number;
105 /**
106 * Which metadata fields to include in reported logs.
107 * @default "INCLUDE_ALL_METADATA"
108 */
109 metadata?: OrganizationsVpcFlowLogsConfigMetadata;
110 /**
111 * Custom metadata fields. Only valid when `metadata` is
112 * `CUSTOM_METADATA`.
113 */
114 metadataFields?: string[];
115 /**
116 * CEL export filter selecting which VPC Flow Logs are recorded.
117 */
118 filterExpr?: string;
119 /**
120 * Whether to include cross-project annotations. Organization configs
121 * only.
122 * @default "CROSS_PROJECT_METADATA_ENABLED"
123 */
124 crossProjectMetadata?: OrganizationsVpcFlowLogsConfigCrossProjectMetadata;
125 /**
126 * Optional VPC network to log. Organization configs may omit every
127 * target to apply across the organization. Changing the target
128 * resource replaces the config.
129 */
130 network?: string;
131 /**
132 * Optional subnetwork to log. Changing the target resource replaces
133 * the config.
134 */
135 subnet?: string;
136 /**
137 * Optional interconnect attachment to log. Changing the target
138 * resource replaces the config.
139 */
140 interconnectAttachment?: string;
141 /**
142 * Optional VPN tunnel to log. Changing the target resource replaces
143 * the config.
144 */
145 vpnTunnel?: string;
146 /**
147 * User labels. Alchemy ownership labels are merged in automatically.
148 */
149 labels?: Record<string, string>;
150};
151
152export type OrganizationsVpcFlowLogsConfig = Resource<
153 "GCP.NetworkManagement.OrganizationsVpcFlowLogsConfig",
154 OrganizationsVpcFlowLogsConfigProps,
155 {
156 /** Full resource name `organizations/{organization}/locations/global/vpcFlowLogsConfigs/{vpc_flow_logs_config}`. */
157 name: string;
158 /** Config id (last path segment). */
159 vpcFlowLogsConfigId: string;
160 /** Organization id. */
161 organization: string;
162 /** Location id (`global`). */
163 location: string;
164 /** User-provided description. */
165 description: string | undefined;
166 /** Whether log generation is enabled. */
167 state: string | undefined;
168 /** Aggregation interval. */
169 aggregationInterval: string | undefined;
170 /** Sampling rate. */
171 flowSampling: number | undefined;
172 /** Metadata inclusion mode. */
173 metadata: string | undefined;
174 /** Custom metadata fields. */
175 metadataFields: string[];
176 /** CEL export filter. */
177 filterExpr: string | undefined;
178 /** Cross-project annotation mode. */
179 crossProjectMetadata: string | undefined;
180 /** Diagnostic state of the configured target resource. */
181 targetResourceState: string | undefined;
182 /** Target VPC network, if set. */
183 network: string | undefined;
184 /** Target subnetwork, if set. */
185 subnet: string | undefined;
186 /** Target interconnect attachment, if set. */
187 interconnectAttachment: string | undefined;
188 /** Target VPN tunnel, if set. */
189 vpnTunnel: string | undefined;
190 /** User labels (Alchemy ownership labels stripped). */
191 labels: Record<string, string>;
192 /** RFC3339 creation timestamp. */
193 createTime: string | undefined;
194 /** RFC3339 last-update timestamp. */
195 updateTime: string | undefined;
196 },
197 never,
198 Providers
199>;
200
201/**
202 * An organization-level VPC Flow Logs configuration.
203 *
204 * Organization configs apply across the organization when no target
205 * resource is set. Changing the id, organization, location, or target
206 * replaces the config. Description, labels, state, sampling, metadata,
207 * cross-project annotations, and the export filter update in place.
208 *
209 * ### Creating an Organization VpcFlowLogsConfig
210 * **Example:** Organization-wide logs
211 * ```typescript
212 * const logs = yield* GCP.NetworkManagement.OrganizationsVpcFlowLogsConfig(
213 * "OrgLogs",
214 * {
215 * aggregationInterval: "INTERVAL_1_MIN",
216 * labels: { env: "prod" },
217 * },
218 * );
219 * ```
220 *
221 * **Example:** Named org config
222 * ```typescript
223 * const logs = yield* GCP.NetworkManagement.OrganizationsVpcFlowLogsConfig(
224 * "OrgLogs",
225 * {
226 * vpcFlowLogsConfigId: "org-vpc-logs",
227 * organization: "123456789",
228 * description: "org vpc flow logs",
229 * crossProjectMetadata: "CROSS_PROJECT_METADATA_ENABLED",
230 * },
231 * );
232 * ```
233 *
234 * @resource
235 * @category NetworkManagement
236 */
237export const OrganizationsVpcFlowLogsConfig =
238 Resource<OrganizationsVpcFlowLogsConfig>(
239 "GCP.NetworkManagement.OrganizationsVpcFlowLogsConfig",
240 );
241
242const resourceName = (
243 organization: string,
244 location: string,
245 vpcFlowLogsConfigId: string,
246) =>
247 organizationResourceName(
248 organization,
249 location,
250 COLLECTION,
251 vpcFlowLogsConfigId,
252 );
253
254const stateOf = (value: string | undefined) =>
255 (value ?? DEFAULT_STATE).toUpperCase();
256
257const intervalOf = (value: string | undefined) =>
258 (value ?? DEFAULT_INTERVAL).toUpperCase();
259
260const metadataOf = (value: string | undefined) =>
261 (value ?? DEFAULT_METADATA).toUpperCase();
262
263const crossProjectOf = (value: string | undefined) =>
264 (value ?? DEFAULT_CROSS_PROJECT).toUpperCase();
265
266const samplingOf = (value: number | undefined) => value ?? DEFAULT_SAMPLING;
267
268const targetKey = (props: {
269 network?: string;
270 subnet?: string;
271 interconnectAttachment?: string;
272 vpnTunnel?: string;
273}) =>
274 JSON.stringify({
275 network: lastSegmentOf(props.network),
276 subnet: lastSegmentOf(props.subnet),
277 interconnectAttachment: lastSegmentOf(props.interconnectAttachment),
278 vpnTunnel: lastSegmentOf(props.vpnTunnel),
279 });
280
281const lastSegmentOf = (value: string | undefined) =>
282 (value ?? "")
283 .replace(/^https?:\/\/[^/]+\//, "")
284 .replace(/\/+$/, "")
285 .split("/")
286 .pop()
287 ?.toLowerCase() ?? "";
288
289const toTarget = (
290 project: string,
291 region: string,
292 news: {
293 network?: string;
294 subnet?: string;
295 interconnectAttachment?: string;
296 vpnTunnel?: string;
297 },
298) => ({
299 network:
300 news.network !== undefined
301 ? toNetworkResource(project, news.network)
302 : undefined,
303 subnet:
304 news.subnet !== undefined
305 ? toSubnetworkResource(project, region, news.subnet)
306 : undefined,
307 interconnectAttachment:
308 news.interconnectAttachment !== undefined
309 ? toRegionalComputeResource(
310 project,
311 region,
312 "interconnectAttachments",
313 news.interconnectAttachment,
314 )
315 : undefined,
316 vpnTunnel:
317 news.vpnTunnel !== undefined
318 ? toRegionalComputeResource(project, region, "vpnTunnels", news.vpnTunnel)
319 : undefined,
320});
321
322const toAttrs = (config: networkmanagement.VpcFlowLogsConfig) => {
323 const name = config.name ?? "";
324 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
325 return {
326 name,
327 vpcFlowLogsConfigId: parsed.id,
328 organization: parsed.organization,
329 location: parsed.location || DEFAULT_GLOBAL,
330 description: config.description,
331 state: config.state,
332 aggregationInterval: config.aggregationInterval,
333 flowSampling: config.flowSampling,
334 metadata: config.metadata,
335 metadataFields: config.metadataFields ?? [],
336 filterExpr: config.filterExpr,
337 crossProjectMetadata: config.crossProjectMetadata,
338 targetResourceState: config.targetResourceState,
339 network: config.network,
340 subnet: config.subnet,
341 interconnectAttachment: config.interconnectAttachment,
342 vpnTunnel: config.vpnTunnel,
343 labels: userLabels(config.labels),
344 createTime: config.createTime,
345 updateTime: config.updateTime,
346 };
347};
348
349const getByName = (name: string) =>
350 networkmanagement
351 .getOrganizationsLocationsVpcFlowLogsConfigs({ name })
352 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
353
354const listOwned = (organization: string) =>
355 collectPages(
356 networkmanagement.listOrganizationsLocationsVpcFlowLogsConfigs.pages({
357 parent: organizationParent(organization, DEFAULT_GLOBAL),
358 pageSize: 1000,
359 }),
360 (page) => page.vpcFlowLogsConfigs,
361 ).pipe(
362 Effect.map((items) =>
363 items.filter((item) => hasAlchemyLabelKeys(item.labels)).map(toAttrs),
364 ),
365 );
366
367export const OrganizationsVpcFlowLogsConfigProvider = () =>
368 Provider.succeed(OrganizationsVpcFlowLogsConfig, {
369 stables: [
370 "name",
371 "vpcFlowLogsConfigId",
372 "organization",
373 "location",
374 "createTime",
375 ],
376
377 diff: Effect.fn(function* ({ news, olds, output }) {
378 if (!isResolved(news)) return undefined;
379 const previousId =
380 olds?.vpcFlowLogsConfigId ?? output?.vpcFlowLogsConfigId;
381 const nextId = news.vpcFlowLogsConfigId
382 ? rfc1035(news.vpcFlowLogsConfigId, "vpc-flow-logs")
383 : previousId;
384 const previousOrg = olds?.organization ?? output?.organization;
385 const nextOrg = news.organization ?? previousOrg;
386 const previousLocation = normalizeLocation(
387 olds?.location ?? output?.location,
388 DEFAULT_GLOBAL,
389 );
390 const nextLocation = normalizeLocation(
391 news.location ?? olds?.location ?? output?.location,
392 DEFAULT_GLOBAL,
393 );
394 const previousTarget = targetKey({
395 network: olds?.network ?? output?.network,
396 subnet: olds?.subnet ?? output?.subnet,
397 interconnectAttachment:
398 olds?.interconnectAttachment ?? output?.interconnectAttachment,
399 vpnTunnel: olds?.vpnTunnel ?? output?.vpnTunnel,
400 });
401 const nextTarget = targetKey({
402 network: news.network,
403 subnet: news.subnet,
404 interconnectAttachment: news.interconnectAttachment,
405 vpnTunnel: news.vpnTunnel,
406 });
407 if (
408 (previousId !== undefined &&
409 nextId !== undefined &&
410 nextId !== previousId) ||
411 (previousOrg !== undefined &&
412 nextOrg !== undefined &&
413 nextOrg !== previousOrg) ||
414 previousLocation !== nextLocation ||
415 previousTarget !== nextTarget
416 ) {
417 return { action: "replace" as const };
418 }
419 return undefined;
420 }),
421
422 read: Effect.fn(function* ({ id, olds, output }) {
423 const vpcFlowLogsConfigId = yield* toPhysicalId(
424 id,
425 olds?.vpcFlowLogsConfigId,
426 output?.vpcFlowLogsConfigId,
427 "vpc-flow-logs",
428 );
429 const organization = yield* resolveOrganization(
430 olds?.organization ?? output?.organization,
431 output?.organization,
432 ).pipe(
433 Effect.catchTag("GCP.NetworkManagement.OrganizationRequired", () =>
434 Effect.succeed(output?.organization ?? ""),
435 ),
436 );
437 const location = normalizeLocation(
438 olds?.location ?? output?.location,
439 DEFAULT_GLOBAL,
440 );
441 const name =
442 output?.name ??
443 (organization.length > 0
444 ? resourceName(organization, location, vpcFlowLogsConfigId)
445 : "");
446 if (name.length === 0) return undefined;
447 const existing = yield* getByName(name);
448 if (existing === undefined) return undefined;
449 const attrs = toAttrs(existing);
450 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
451 ? attrs
452 : Unowned(attrs);
453 }),
454
455 list: () =>
456 Effect.gen(function* () {
457 const env = yield* GcpEnvironment.current;
458 const orgs = yield* listOrganizations(env.project);
459 const listed: OrganizationsVpcFlowLogsConfig["Attributes"][] = [];
460 for (const organization of orgs) {
461 listed.push(...(yield* listOwned(organization)));
462 }
463 return listed;
464 }),
465
466 reconcile: Effect.fn(function* ({ id, news, output }) {
467 const env = yield* GcpEnvironment.current;
468 const vpcFlowLogsConfigId = yield* toPhysicalId(
469 id,
470 news.vpcFlowLogsConfigId,
471 output?.vpcFlowLogsConfigId,
472 "vpc-flow-logs",
473 );
474 const organization = yield* resolveOrganization(
475 news.organization ?? output?.organization,
476 output?.organization,
477 );
478 const location = normalizeLocation(
479 news.location ?? output?.location,
480 DEFAULT_GLOBAL,
481 );
482 const name = resourceName(organization, location, vpcFlowLogsConfigId);
483 const desiredLabels = {
484 ...toLabels(news.labels),
485 ...(yield* createInternalLabels(id)),
486 };
487 const target = toTarget(env.project, env.region, news);
488 const state = stateOf(news.state);
489 const createState = state === "DISABLED" ? DEFAULT_STATE : state;
490 const aggregationInterval = intervalOf(news.aggregationInterval);
491 const flowSampling = samplingOf(news.flowSampling);
492 const metadata = metadataOf(news.metadata);
493 const metadataFields = news.metadataFields ?? [];
494 const filterExpr = news.filterExpr;
495 const crossProjectMetadata = crossProjectOf(news.crossProjectMetadata);
496
497 let current = yield* getByName(name);
498
499 if (current === undefined) {
500 const created = yield* networkmanagement
501 .createOrganizationsLocationsVpcFlowLogsConfigs({
502 parent: organizationParent(organization, location),
503 vpcFlowLogsConfigId,
504 body: {
505 description: news.description,
506 labels: desiredLabels,
507 state: createState,
508 aggregationInterval,
509 flowSampling,
510 metadata,
511 metadataFields:
512 metadataFields.length > 0 ? metadataFields : undefined,
513 filterExpr,
514 crossProjectMetadata,
515 ...target,
516 },
517 })
518 .pipe(
519 Effect.retry({
520 while: (error) => error._tag === "Conflict",
521 times: 5,
522 schedule: Schedule.spaced("2 seconds"),
523 }),
524 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
525 );
526 if (created !== undefined) {
527 yield* waitForOperation(created);
528 }
529 current = yield* waitUntilPresent(getByName(name), name);
530 }
531
532 const observedLabels = tagRecord(current.labels);
533 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
534 const labelsChanged = upsert.length > 0 || removed.length > 0;
535 const descriptionChanged =
536 (current.description ?? "") !== (news.description ?? "");
537 const stateChanged = (current.state ?? DEFAULT_STATE) !== state;
538 const intervalChanged =
539 (current.aggregationInterval ?? DEFAULT_INTERVAL) !==
540 aggregationInterval;
541 const samplingChanged =
542 (current.flowSampling ?? DEFAULT_SAMPLING) !== flowSampling;
543 const metadataChanged =
544 (current.metadata ?? DEFAULT_METADATA) !== metadata;
545 const metadataFieldsChanged = !sameStringList(
546 current.metadataFields,
547 metadataFields,
548 );
549 const filterChanged = (current.filterExpr ?? "") !== (filterExpr ?? "");
550 const crossProjectChanged =
551 (current.crossProjectMetadata ?? DEFAULT_CROSS_PROJECT) !==
552 crossProjectMetadata;
553
554 const updateMask = changedFields([
555 ["labels", labelsChanged],
556 ["description", descriptionChanged],
557 ["state", stateChanged],
558 ["aggregationInterval", intervalChanged],
559 ["flowSampling", samplingChanged],
560 ["metadata", metadataChanged],
561 ["metadataFields", metadataFieldsChanged],
562 ["filterExpr", filterChanged],
563 ["crossProjectMetadata", crossProjectChanged],
564 ]);
565
566 if (updateMask.length > 0) {
567 const operation =
568 yield* networkmanagement.patchOrganizationsLocationsVpcFlowLogsConfigs(
569 {
570 name: current.name ?? name,
571 updateMask: updateMask.join(","),
572 body: {
573 name: current.name ?? name,
574 labels: desiredLabels,
575 description: news.description,
576 state,
577 aggregationInterval,
578 flowSampling,
579 metadata,
580 metadataFields,
581 filterExpr,
582 crossProjectMetadata,
583 },
584 },
585 );
586 yield* waitForOperation(operation);
587 current = yield* waitUntilPresent(
588 getByName(current.name ?? name),
589 current.name ?? name,
590 );
591 }
592
593 return toAttrs(current);
594 }),
595
596 delete: Effect.fn(function* ({ output }) {
597 const operation = yield* networkmanagement
598 .deleteOrganizationsLocationsVpcFlowLogsConfigs({ name: output.name })
599 .pipe(
600 Effect.retry({
601 while: (error) => error._tag === "Conflict",
602 times: 8,
603 schedule: Schedule.spaced("2 seconds"),
604 }),
605 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
606 );
607 if (operation !== undefined) {
608 yield* waitForOperation(operation, { notFoundOk: true });
609 }
610 yield* waitUntilGone(getByName(output.name), output.name);
611 }),
612 });