1import * as iap from "@distilled.cloud/gcp/iap_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 destGroupNameOf,
12 destGroupParent,
13 getDestGroup,
14 normalizeLocation,
15 parseDestGroupName,
16 replaceOnIdentity,
17 sameStringList,
18 toDestGroupId,
19 uniqueStrings,
20 updateMaskOf,
21 waitUntilGone,
22} from "./internal.ts";
23
24export type IapDestGroupProps = {
25 /**
26 * Destination group id (the `{dest_group}` segment of
27 * `projects/{project}/iap_tunnel/locations/{location}/destGroups/{dest_group}`).
28 * If omitted, a unique name is generated. Must be 4-63 characters of
29 * lowercase letters and dashes. Immutable — changing it replaces the
30 * group.
31 */
32 destGroupId?: string;
33 /**
34 * Region of the destination group (`us-central1`, …). Immutable —
35 * changing it replaces the group. `US-CENTRAL1` is accepted and
36 * normalized to `us-central1`.
37 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
38 */
39 location?: string;
40 /**
41 * CIDR ranges this group matches (for example `10.1.0.0/16`).
42 */
43 cidrs?: string[];
44 /**
45 * Fully qualified domain names this group matches.
46 */
47 fqdns?: string[];
48};
49
50export type IapDestGroup = Resource<
51 "GCP.IAP.IapDestGroup",
52 IapDestGroupProps,
53 {
54 /** Full resource name `projects/{project}/iap_tunnel/locations/{location}/destGroups/{dest_group}`. */
55 name: string;
56 /** Destination group id (last path segment). */
57 destGroupId: string;
58 /** Project id. */
59 project: string;
60 /** Location id. */
61 location: string;
62 /** CIDR ranges this group matches. */
63 cidrs: string[];
64 /** User FQDNs with the Alchemy ownership FQDN stripped. */
65 fqdns: string[];
66 },
67 never,
68 Providers
69>;
70
71/**
72 * An Identity-Aware Proxy TCP-forwarding tunnel destination group.
73 *
74 * Destination groups have no labels field, so Alchemy identifies its
75 * groups by the generated `destGroupId`: `read` reports a group with an
76 * explicit `destGroupId` as unowned (adopt it with `--adopt`), and nuke
77 * cannot discover groups. `destGroupId` and `location` are identity —
78 * changing either replaces the group. CIDRs and FQDNs update in place.
79 *
80 * ### Creating a Destination Group
81 * **Example:** Generated name with CIDRs
82 * ```typescript
83 * const group = yield* GCP.IAP.IapDestGroup("SshHosts", {
84 * cidrs: ["10.1.0.0/16"],
85 * });
86 * ```
87 *
88 * **Example:** Named group with FQDNs
89 * ```typescript
90 * const group = yield* GCP.IAP.IapDestGroup("SshHosts", {
91 * destGroupId: "prod-ssh",
92 * location: "us-central1",
93 * fqdns: ["*.internal.example.com"],
94 * });
95 * ```
96 *
97 * ### Updating a Destination Group
98 * **Example:** Add CIDRs and FQDNs
99 * ```typescript
100 * const group = yield* GCP.IAP.IapDestGroup("SshHosts", {
101 * cidrs: ["10.1.0.0/16", "192.168.2.0/24"],
102 * fqdns: ["db.internal.example.com"],
103 * });
104 * ```
105 *
106 * @resource
107 * @category IAP
108 */
109export const IapDestGroup = Resource<IapDestGroup>("GCP.IAP.IapDestGroup");
110
111export class IapDestGroupNotResolved extends Data.TaggedError(
112 "GCP.IAP.IapDestGroupNotResolved",
113)<{
114 name: string;
115}> {}
116
117const toAttrs = (
118 group: iap.TunnelDestGroup,
119 project: string,
120 region: string,
121) => {
122 const name = group.name ?? "";
123 const parsed = parseDestGroupName(name, project, region);
124 return {
125 name,
126 destGroupId: parsed.destGroupId,
127 project,
128 location: parsed.location,
129 cidrs: uniqueStrings(group.cidrs),
130 fqdns: uniqueStrings(group.fqdns),
131 };
132};
133
134export const IapDestGroupProvider = () =>
135 Provider.succeed(IapDestGroup, {
136 stables: ["name", "destGroupId", "project", "location"],
137
138 diff: Effect.fn(function* ({ news, olds, output }) {
139 if (!isResolved(news)) return undefined;
140 const env = yield* GcpEnvironment.current;
141 return replaceOnIdentity({
142 previousId: olds?.destGroupId ?? output?.destGroupId,
143 nextId: news.destGroupId,
144 previousParent: normalizeLocation(
145 olds?.location ?? output?.location,
146 env.region,
147 ),
148 nextParent:
149 news.location !== undefined
150 ? normalizeLocation(news.location, env.region)
151 : undefined,
152 });
153 }),
154
155 read: Effect.fn(function* ({ id, olds, output }) {
156 const env = yield* GcpEnvironment.current;
157 const location = normalizeLocation(
158 olds?.location ?? output?.location,
159 env.region,
160 );
161 const destGroupId = yield* toDestGroupId(
162 id,
163 olds?.destGroupId,
164 output?.destGroupId,
165 );
166 const name =
167 output?.name ?? destGroupNameOf(env.project, location, destGroupId);
168 const existing = yield* getDestGroup(name);
169 if (existing === undefined) return undefined;
170 const attrs = toAttrs(existing, env.project, env.region);
171 const generated = yield* toDestGroupId(id, undefined, undefined);
172 return attrs.destGroupId === generated ? attrs : Unowned(attrs);
173 }),
174
175 reconcile: Effect.fn(function* ({ id, news, output }) {
176 const env = yield* GcpEnvironment.current;
177 const location = normalizeLocation(
178 news.location ?? output?.location,
179 env.region,
180 );
181 const destGroupId = yield* toDestGroupId(
182 id,
183 news.destGroupId,
184 output?.destGroupId,
185 );
186 const name = destGroupNameOf(env.project, location, destGroupId);
187 const parent = destGroupParent(env.project, location);
188 const cidrs = uniqueStrings(news.cidrs);
189 const fqdns = uniqueStrings(news.fqdns);
190
191 let current = yield* getDestGroup(output?.name ?? name);
192
193 if (current === undefined) {
194 const created = yield* iap
195 .createProjectsIap_tunnelLocationsDestGroups({
196 parent,
197 tunnelDestGroupId: destGroupId,
198 body: { name, cidrs, fqdns },
199 })
200 .pipe(Effect.catchTag("Conflict", () => getDestGroup(name)));
201 current = created ?? undefined;
202 }
203
204 if (current === undefined) {
205 return yield* new IapDestGroupNotResolved({ name });
206 }
207
208 const currentName = current.name ?? name;
209 const cidrsChanged = !sameStringList(current.cidrs, cidrs);
210 const fqdnsChanged = !sameStringList(current.fqdns, fqdns);
211 if (cidrsChanged || fqdnsChanged) {
212 current = yield* iap.patchProjectsIap_tunnelLocationsDestGroups({
213 name: currentName,
214 updateMask: updateMaskOf(
215 cidrsChanged ? "cidrs" : undefined,
216 fqdnsChanged ? "fqdns" : undefined,
217 ),
218 body: { name: currentName, cidrs, fqdns },
219 });
220 }
221
222 return toAttrs(current, env.project, env.region);
223 }),
224
225 delete: Effect.fn(function* ({ output }) {
226 if (!output.name) return;
227 yield* iap
228 .deleteProjectsIap_tunnelLocationsDestGroups({
229 name: output.name,
230 })
231 .pipe(Effect.catchTag("NotFound", () => Effect.void));
232 yield* waitUntilGone(getDestGroup(output.name));
233 }),
234 });