1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 collectPages,
12 createOwnership,
13 defaultOrgName,
14 encodeDescription,
15 hasOwnershipMarker,
16 lastSegment,
17 letterPrefixedId,
18 listOrgNames,
19 orgIdOf,
20 orgNameOf,
21 ownedBy,
22 parseDescription,
23 waitForOperation,
24} from "./operations.ts";
25
26export type DnsZonePeeringConfig = {
27 /** VPC network whose private DNS namespace Apigee peers with. */
28 targetNetworkId: string;
29 /** Project id that contains `targetNetworkId`. */
30 targetProjectId: string;
31};
32
33export type DnsZoneProps = {
34 /**
35 * Apigee organization id or `organizations/{org}`. Defaults to the
36 * current GCP project id. Immutable — changing it replaces the zone.
37 */
38 organization?: string;
39 /**
40 * DNS zone id (the `{dns_zone}` segment of
41 * `organizations/{org}/dnsZones/{dns_zone}`). Must be 1-63 characters,
42 * begin with a letter, end with a letter or digit, and contain only
43 * lowercase letters, digits, or dashes. If omitted, a unique name is
44 * generated. Immutable — changing it replaces the zone.
45 */
46 dnsZoneId?: string;
47 /**
48 * Domain name for hosts in this private zone (for example
49 * `example.com.`). Immutable — changing it replaces the zone.
50 */
51 domain: string;
52 /**
53 * DNS peering configuration. Immutable — changing it replaces the zone.
54 */
55 peeringConfig: DnsZonePeeringConfig;
56 /**
57 * Human-readable description (required by Apigee, max 1024 characters).
58 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
59 * from attributes.
60 */
61 description?: string;
62};
63
64export type DnsZone = Resource<
65 "GCP.Apigee.DnsZone",
66 DnsZoneProps,
67 {
68 /** Full resource name `organizations/{org}/dnsZones/{dns_zone}`. */
69 name: string;
70 /** DNS zone id (last path segment). */
71 dnsZoneId: string;
72 /** Apigee organization id. */
73 organization: string;
74 /** Peered domain. */
75 domain: string;
76 /** DNS peering configuration. */
77 peeringConfig: DnsZonePeeringConfig | undefined;
78 /** User description with the Alchemy ownership prefix stripped. */
79 description: string | undefined;
80 /** Server-reported state (`CREATING`, `ACTIVE`, …). */
81 state: string | undefined;
82 /** RFC3339 creation timestamp. */
83 createTime: string | undefined;
84 /** RFC3339 last-update timestamp. */
85 updateTime: string | undefined;
86 },
87 never,
88 Providers
89>;
90
91/**
92 * An Apigee DNS peering zone that lets runtime instances resolve hostnames
93 * in a peered VPC.
94 *
95 * Apigee DNS zones have no labels field, so Alchemy stamps ownership into
96 * the description for `list` / nuke. Name, domain, and peering config are
97 * identity — changing them replaces the zone. Description updates in place
98 * only if the API later exposes a patch; today the resource is
99 * existence-only besides identity.
100 *
101 * ### Creating a DNS Zone
102 * **Example:** Peer with the default VPC
103 * ```typescript
104 * const zone = yield* GCP.Apigee.DnsZone("PrivateDns", {
105 * domain: "internal.example.com.",
106 * peeringConfig: {
107 * targetNetworkId: "default",
108 * targetProjectId: "my-project",
109 * },
110 * description: "runtime DNS peering",
111 * });
112 * ```
113 *
114 * @resource
115 * @category Apigee
116 */
117export const DnsZone = Resource<DnsZone>("GCP.Apigee.DnsZone");
118
119/** Alias matching the catalog name `Dn`. */
120export const Dn = DnsZone;
121
122export class DnsZoneNotResolved extends Data.TaggedError(
123 "GCP.Apigee.DnsZoneNotResolved",
124)<{
125 name: string;
126}> {}
127
128const resourceName = (organization: string, dnsZoneId: string) =>
129 `${orgNameOf(organization)}/dnsZones/${dnsZoneId}`;
130
131const toAttrs = (
132 zone: apigee.GoogleCloudApigeeV1DnsZone,
133 organization: string,
134) => {
135 const name = zone.name ?? "";
136 const parsed = parseDescription(zone.description);
137 const peering = zone.peeringConfig;
138 return {
139 name,
140 dnsZoneId: lastSegment(name),
141 organization: orgIdOf(organization),
142 domain: zone.domain ?? "",
143 peeringConfig:
144 peering?.targetNetworkId !== undefined ||
145 peering?.targetProjectId !== undefined
146 ? {
147 targetNetworkId: peering.targetNetworkId ?? "",
148 targetProjectId: peering.targetProjectId ?? "",
149 }
150 : undefined,
151 description: parsed.description,
152 state: zone.state,
153 createTime: zone.createTime,
154 updateTime: zone.updateTime,
155 };
156};
157
158const getByName = (name: string) =>
159 apigee
160 .getOrganizationsDnsZones({ name })
161 .pipe(
162 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
163 Effect.succeed(undefined),
164 ),
165 );
166
167export const DnsZoneProvider = () =>
168 Provider.succeed(DnsZone, {
169 stables: ["name", "dnsZoneId", "organization", "domain", "createTime"],
170
171 diff: Effect.fn(function* ({ news, olds, output }) {
172 if (!isResolved(news)) return undefined;
173 const previousId = olds?.dnsZoneId ?? output?.dnsZoneId;
174 const previousOrg = olds?.organization ?? output?.organization;
175 const previousDomain = olds?.domain ?? output?.domain;
176 const previousPeer = olds?.peeringConfig ?? output?.peeringConfig;
177 const idChanged =
178 previousId !== undefined &&
179 news.dnsZoneId !== undefined &&
180 news.dnsZoneId !== previousId;
181 const orgChanged =
182 previousOrg !== undefined &&
183 news.organization !== undefined &&
184 orgIdOf(news.organization) !== orgIdOf(previousOrg);
185 const domainChanged =
186 previousDomain !== undefined && news.domain !== previousDomain;
187 const peerChanged =
188 previousPeer !== undefined &&
189 (previousPeer.targetNetworkId !== news.peeringConfig.targetNetworkId ||
190 previousPeer.targetProjectId !== news.peeringConfig.targetProjectId);
191 if (idChanged || orgChanged || domainChanged || peerChanged) {
192 return {
193 action: "replace" as const,
194 deleteFirst: idChanged && !orgChanged,
195 };
196 }
197 return undefined;
198 }),
199
200 read: Effect.fn(function* ({ id, olds, output }) {
201 const env = yield* GcpEnvironment.current;
202 const organization = defaultOrgName(
203 env.project,
204 olds?.organization ?? output?.organization,
205 );
206 const dnsZoneId = yield* letterPrefixedId(
207 id,
208 olds?.dnsZoneId,
209 output?.dnsZoneId,
210 63,
211 );
212 const name = output?.name ?? resourceName(organization, dnsZoneId);
213 const existing = yield* getByName(name);
214 if (existing === undefined) return undefined;
215 const attrs = toAttrs(existing, organization);
216 const { labels } = parseDescription(existing.description);
217 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
218 }),
219
220 list: () =>
221 Effect.gen(function* () {
222 const orgs = yield* listOrgNames();
223 const rows: DnsZone["Attributes"][] = [];
224 for (const organization of orgs) {
225 const zones = yield* collectPages(
226 apigee.listOrganizationsDnsZones.pages({
227 parent: organization,
228 pageSize: 1000,
229 }),
230 (page) => page.dnsZones,
231 ).pipe(
232 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
233 Effect.succeed([] as apigee.GoogleCloudApigeeV1DnsZone[]),
234 ),
235 );
236 for (const zone of zones) {
237 if (hasOwnershipMarker(zone.description)) {
238 rows.push(toAttrs(zone, organization));
239 }
240 }
241 }
242 return rows;
243 }),
244
245 reconcile: Effect.fn(function* ({ id, news, output }) {
246 const env = yield* GcpEnvironment.current;
247 const organization = defaultOrgName(env.project, news.organization);
248 const dnsZoneId = yield* letterPrefixedId(
249 id,
250 news.dnsZoneId,
251 output?.dnsZoneId,
252 63,
253 );
254 const name = resourceName(organization, dnsZoneId);
255 const ownership = yield* createOwnership(id);
256 const desiredDescription = encodeDescription(ownership, news.description);
257
258 let current = yield* getByName(output?.name ?? name);
259
260 if (current === undefined) {
261 const operation = yield* apigee
262 .createOrganizationsDnsZones({
263 parent: organization,
264 dnsZoneId,
265 body: {
266 description: desiredDescription,
267 domain: news.domain,
268 peeringConfig: news.peeringConfig,
269 },
270 })
271 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
272 if (operation !== undefined) {
273 yield* waitForOperation(operation);
274 }
275 current = yield* getByName(name);
276 }
277
278 if (current === undefined) {
279 return yield* new DnsZoneNotResolved({ name });
280 }
281
282 return toAttrs(current, organization);
283 }),
284
285 delete: Effect.fn(function* ({ output }) {
286 const operation = yield* apigee
287 .deleteOrganizationsDnsZones({ name: output.name })
288 .pipe(
289 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
290 Effect.succeed(undefined),
291 ),
292 );
293 if (operation !== undefined) {
294 yield* waitForOperation(operation, { notFoundOk: true });
295 }
296 }),
297 });