1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_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 canonicalizeLink,
20 changedFields,
21 collectPages,
22 hasAlchemyLabelKeys,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 rfc1035,
27 sameStringList,
28 toPhysicalId,
29 userLabels,
30 waitUntilGone,
31 waitUntilPresent,
32 NetworksecurityNotResolved,
33} from "./internal.ts";
34
35const COLLECTION = "dnsThreatDetectors";
36const DEFAULT_PROVIDER = "INFOBLOX";
37
38export type DnsThreatDetectorProvider =
39 | networksecurity.DnsThreatDetectorProviderEnum
40 | (string & {});
41
42export type DnsThreatDetectorProps = {
43 /**
44 * DnsThreatDetector id (the `{dnsThreatDetector}` segment of
45 * `projects/{project}/locations/global/dnsThreatDetectors/{dnsThreatDetector}`).
46 * If omitted, a unique name is generated from the stack, stage, and
47 * logical id. Immutable — changing it replaces the detector. A project
48 * may have only one DNS threat detector.
49 */
50 dnsThreatDetectorId?: string;
51 /**
52 * Location. The only supported value is `global`. Immutable —
53 * changing it replaces the detector.
54 * @default "global"
55 */
56 location?: string;
57 /**
58 * DNS threat analysis provider. Currently only `INFOBLOX` is
59 * supported. Immutable — changing it replaces the detector.
60 * @default "INFOBLOX"
61 */
62 provider?: DnsThreatDetectorProvider;
63 /**
64 * VPC network resource names that this detector should not monitor,
65 * e.g. `projects/PROJECT_ID/global/networks/NETWORK_NAME`.
66 */
67 excludedNetworks?: string[];
68 /**
69 * User labels. Alchemy ownership labels are merged in automatically.
70 */
71 labels?: Record<string, string>;
72};
73
74export type DnsThreatDetector = Resource<
75 "GCP.NetworkSecurity.DnsThreatDetector",
76 DnsThreatDetectorProps,
77 {
78 /** Full resource name `projects/{project}/locations/global/dnsThreatDetectors/{dnsThreatDetector}`. */
79 name: string;
80 /** DnsThreatDetector id (last path segment). */
81 dnsThreatDetectorId: string;
82 /** Project id. */
83 project: string;
84 /** Location id — always `"global"`. */
85 location: string;
86 /** Threat analysis provider (`INFOBLOX`). */
87 provider: string | undefined;
88 /** VPC networks excluded from monitoring. */
89 excludedNetworks: string[];
90 /** User labels (Alchemy ownership labels stripped). */
91 labels: Record<string, string>;
92 /** RFC3339 creation timestamp. */
93 createTime: string | undefined;
94 /** RFC3339 last-update timestamp. */
95 updateTime: string | undefined;
96 },
97 never,
98 Providers
99>;
100
101/**
102 * A DNS threat detector that sends VPC DNS query logs to Infoblox for
103 * analysis. By default every VPC in the project is monitored; pass
104 * `excludedNetworks` to skip specific networks.
105 *
106 * A project may have only one detector. Changing `dnsThreatDetectorId`,
107 * `location`, or `provider` replaces the resource. Labels and
108 * `excludedNetworks` update in place.
109 *
110 * ### Creating a DnsThreatDetector
111 * **Example:** Monitor every VPC
112 * ```typescript
113 * const detector = yield* GCP.NetworkSecurity.DnsThreatDetector("Armor", {
114 * provider: "INFOBLOX",
115 * });
116 * ```
117 *
118 * **Example:** Exclude a network
119 * ```typescript
120 * const detector = yield* GCP.NetworkSecurity.DnsThreatDetector("Armor", {
121 * provider: "INFOBLOX",
122 * excludedNetworks: [
123 * `projects/${project}/global/networks/${vpc.networkName}`,
124 * ],
125 * labels: { env: "prod" },
126 * });
127 * ```
128 *
129 * @resource
130 * @category NetworkSecurity
131 */
132export const DnsThreatDetector = Resource<DnsThreatDetector>(
133 "GCP.NetworkSecurity.DnsThreatDetector",
134);
135
136const resourceName = (
137 project: string,
138 location: string,
139 dnsThreatDetectorId: string,
140) =>
141 `projects/${project}/locations/${location}/dnsThreatDetectors/${dnsThreatDetectorId}`;
142
143const desiredProvider = (news: DnsThreatDetectorProps) =>
144 (news.provider ?? DEFAULT_PROVIDER).toUpperCase();
145
146const toExcluded = (networks: readonly string[] | undefined) =>
147 (networks ?? []).map((network) => canonicalizeLink(network)).filter(Boolean);
148
149const toAttrs = (
150 detector: networksecurity.DnsThreatDetector,
151 project: string,
152) => {
153 const name = detector.name ?? "";
154 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
155 return {
156 name,
157 dnsThreatDetectorId: parsed.id,
158 project: parsed.project || project,
159 location: parsed.location || DEFAULT_GLOBAL,
160 provider: detector.provider,
161 excludedNetworks: toExcluded(detector.excludedNetworks),
162 labels: userLabels(detector.labels),
163 createTime: detector.createTime,
164 updateTime: detector.updateTime,
165 };
166};
167
168const getByName = (name: string) =>
169 networksecurity
170 .getProjectsLocationsDnsThreatDetectors({ name })
171 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
172
173export const DnsThreatDetectorProvider = () =>
174 Provider.succeed(DnsThreatDetector, {
175 stables: [
176 "name",
177 "dnsThreatDetectorId",
178 "project",
179 "location",
180 "provider",
181 "createTime",
182 ],
183
184 diff: Effect.fn(function* ({ news, olds, output }) {
185 if (!isResolved(news)) return undefined;
186 const previousId =
187 olds?.dnsThreatDetectorId ?? output?.dnsThreatDetectorId;
188 const nextId = news.dnsThreatDetectorId
189 ? rfc1035(news.dnsThreatDetectorId, "dns-threat-detector")
190 : previousId;
191 const previousLocation = normalizeLocation(
192 olds?.location ?? output?.location,
193 DEFAULT_GLOBAL,
194 );
195 const nextLocation = normalizeLocation(
196 news.location ?? olds?.location ?? output?.location,
197 DEFAULT_GLOBAL,
198 );
199 const previousProvider = (
200 olds?.provider ??
201 output?.provider ??
202 DEFAULT_PROVIDER
203 ).toUpperCase();
204 const nextProvider = desiredProvider(news);
205 if (
206 (previousId !== undefined &&
207 nextId !== undefined &&
208 nextId !== previousId) ||
209 previousLocation !== nextLocation ||
210 previousProvider !== nextProvider
211 ) {
212 return { action: "replace" as const };
213 }
214 return undefined;
215 }),
216
217 read: Effect.fn(function* ({ id, olds, output }) {
218 const env = yield* GcpEnvironment.current;
219 const dnsThreatDetectorId = yield* toPhysicalId(
220 id,
221 olds?.dnsThreatDetectorId,
222 output?.dnsThreatDetectorId,
223 "dns-threat-detector",
224 );
225 const location = normalizeLocation(
226 olds?.location ?? output?.location,
227 DEFAULT_GLOBAL,
228 );
229 const name =
230 output?.name ??
231 resourceName(env.project, location, dnsThreatDetectorId);
232 const existing = yield* getByName(name);
233 if (existing === undefined) return undefined;
234 const attrs = toAttrs(existing, env.project);
235 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
236 ? attrs
237 : Unowned(attrs);
238 }),
239
240 list: () =>
241 Effect.gen(function* () {
242 const env = yield* GcpEnvironment.current;
243 const items = yield* collectPages(
244 networksecurity.listProjectsLocationsDnsThreatDetectors.pages({
245 parent: parentOf(env.project, "-"),
246 pageSize: 1000,
247 }),
248 (page) => page.dnsThreatDetectors,
249 );
250 return items
251 .filter((item) => hasAlchemyLabelKeys(item.labels))
252 .map((item) => toAttrs(item, env.project));
253 }),
254
255 reconcile: Effect.fn(function* ({ id, news, output }) {
256 const env = yield* GcpEnvironment.current;
257 const dnsThreatDetectorId = yield* toPhysicalId(
258 id,
259 news.dnsThreatDetectorId,
260 output?.dnsThreatDetectorId,
261 "dns-threat-detector",
262 );
263 const location = normalizeLocation(
264 news.location ?? output?.location,
265 DEFAULT_GLOBAL,
266 );
267 const name = resourceName(env.project, location, dnsThreatDetectorId);
268 const desiredLabels = {
269 ...toLabels(news.labels),
270 ...(yield* createInternalLabels(id)),
271 };
272 const provider = desiredProvider(news);
273 const excludedNetworks = toExcluded(news.excludedNetworks);
274
275 let current = yield* getByName(name);
276
277 if (current === undefined) {
278 yield* networksecurity
279 .createProjectsLocationsDnsThreatDetectors({
280 parent: parentOf(env.project, location),
281 dnsThreatDetectorId,
282 body: {
283 provider,
284 labels: desiredLabels,
285 excludedNetworks,
286 },
287 })
288 .pipe(
289 Effect.retry({
290 while: (error) => error._tag === "Conflict",
291 times: 5,
292 schedule: Schedule.spaced("2 seconds"),
293 }),
294 Effect.catchTag("Conflict", () => Effect.void),
295 );
296 current = yield* waitUntilPresent(getByName(name), name);
297 }
298
299 if (current === undefined) {
300 return yield* new NetworksecurityNotResolved({ name });
301 }
302
303 const observedLabels = tagRecord(current.labels);
304 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
305 const labelsChanged = upsert.length > 0 || removed.length > 0;
306 const excludedChanged = !sameStringList(
307 current.excludedNetworks,
308 excludedNetworks,
309 );
310 const updateMask = changedFields([
311 ["labels", labelsChanged],
312 ["excludedNetworks", excludedChanged],
313 ]);
314
315 if (updateMask.length > 0) {
316 yield* networksecurity.patchProjectsLocationsDnsThreatDetectors({
317 name: current.name ?? name,
318 updateMask: updateMask.join(","),
319 body: {
320 name: current.name ?? name,
321 labels: desiredLabels,
322 excludedNetworks,
323 },
324 });
325 current = yield* waitUntilPresent(
326 getByName(current.name ?? name),
327 current.name ?? name,
328 );
329 }
330
331 return toAttrs(current, env.project);
332 }),
333
334 delete: Effect.fn(function* ({ output }) {
335 yield* networksecurity
336 .deleteProjectsLocationsDnsThreatDetectors({ name: output.name })
337 .pipe(
338 Effect.retry({
339 while: (error) => error._tag === "Conflict",
340 times: 8,
341 schedule: Schedule.spaced("2 seconds"),
342 }),
343 Effect.catchTag("NotFound", () => Effect.void),
344 );
345 yield* waitUntilGone(getByName(output.name), output.name);
346 }),
347 });