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 { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 NetworkConnectivityNotResolved,
14 canonicalizeLink,
15 collectPages,
16 hasAlchemyLabelKeys,
17 lastSegment,
18 normalizeLocation,
19 parentOf,
20 parseName,
21 rfc1035,
22 toNetworkResource,
23 toPhysicalId,
24 userLabels,
25 waitForOperation,
26 waitUntilGone,
27 waitUntilPresent,
28} from "./internal.ts";
29
30const COLLECTION = "serviceConnectionTokens";
31
32export type ServiceConnectionTokenProps = {
33 /**
34 * Token id (the `{service_connection_token}` segment). If omitted, a
35 * unique name is generated. Immutable — changing it replaces the
36 * token.
37 */
38 serviceConnectionTokenId?: string;
39 /**
40 * Location (`us-central1`, …). Immutable — changing it replaces the
41 * token. `US-CENTRAL1` is accepted and normalized to `us-central1`.
42 * @default the stack's GCP region (`GCP.Region`, else the profile
43 * region, else `us-central1`)
44 */
45 location?: string;
46 /**
47 * VPC network associated with this token
48 * (`projects/{project}/global/networks/{network}` or a Compute
49 * self-link). Immutable — changing it replaces the token.
50 */
51 network: string;
52 /**
53 * Human-readable description. Set at create; the API has no update.
54 */
55 description?: string;
56 /**
57 * User labels. Alchemy ownership labels are merged in automatically.
58 */
59 labels?: Record<string, string>;
60};
61
62export type ServiceConnectionToken = Resource<
63 "GCP.NetworkConnectivity.ServiceConnectionToken",
64 ServiceConnectionTokenProps,
65 {
66 /** Full resource name `projects/{project}/locations/{location}/serviceConnectionTokens/{id}`. */
67 name: string;
68 /** Token id (last path segment). */
69 serviceConnectionTokenId: string;
70 /** Project id. */
71 project: string;
72 /** Location id. */
73 location: string;
74 /** VPC network resource path. */
75 network: string | undefined;
76 /** Automation-generated token value. */
77 token: string | undefined;
78 /** RFC3339 expiry. */
79 expireTime: string | undefined;
80 /** User-provided description. */
81 description: string | undefined;
82 /** User labels (Alchemy ownership labels stripped). */
83 labels: Record<string, string>;
84 /** Server etag. */
85 etag: string | undefined;
86 /** RFC3339 creation timestamp. */
87 createTime: string | undefined;
88 /** RFC3339 last-update timestamp. */
89 updateTime: string | undefined;
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A PSC Service Connection Token that authenticates a consumer to
97 * create connections in a producer Service Connection Map.
98 *
99 * Changing `serviceConnectionTokenId`, `location`, or `network`
100 * replaces the token. The API has no patch method.
101 *
102 * ### Creating a ServiceConnectionToken
103 * **Example:** Token for a consumer VPC
104 * ```typescript
105 * const network = yield* GCP.Compute.Network("AppVpc", {
106 * autoCreateSubnetworks: false,
107 * });
108 * const token = yield* GCP.NetworkConnectivity.ServiceConnectionToken(
109 * "Consumer",
110 * {
111 * network: network.selfLink ?? network.networkName,
112 * description: "psc token",
113 * labels: { env: "prod" },
114 * },
115 * );
116 * ```
117 *
118 * @resource
119 * @category NetworkConnectivity
120 */
121export const ServiceConnectionToken = Resource<ServiceConnectionToken>(
122 "GCP.NetworkConnectivity.ServiceConnectionToken",
123);
124
125const resourceName = (
126 project: string,
127 location: string,
128 serviceConnectionTokenId: string,
129) =>
130 `projects/${project}/locations/${location}/serviceConnectionTokens/${serviceConnectionTokenId}`;
131
132const toAttrs = (
133 token: networkconnectivity.ServiceConnectionToken,
134 project: string,
135 region: string,
136) => {
137 const name = token.name ?? "";
138 const parsed = parseName(name, COLLECTION, region);
139 return {
140 name,
141 serviceConnectionTokenId: parsed.id,
142 project: parsed.project || project,
143 location: parsed.location || region,
144 network: token.network,
145 token: token.token,
146 expireTime: token.expireTime,
147 description: token.description,
148 labels: userLabels(token.labels),
149 etag: token.etag,
150 createTime: token.createTime,
151 updateTime: token.updateTime,
152 };
153};
154
155const getByName = (name: string) =>
156 networkconnectivity
157 .getProjectsLocationsServiceConnectionTokens({ name })
158 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
159
160export const ServiceConnectionTokenProvider = () =>
161 Provider.succeed(ServiceConnectionToken, {
162 stables: [
163 "name",
164 "serviceConnectionTokenId",
165 "project",
166 "location",
167 "network",
168 "createTime",
169 ],
170
171 diff: Effect.fn(function* ({ news, olds, output }) {
172 if (!isResolved(news)) return undefined;
173 const env = yield* GcpEnvironment.current;
174 const previousId =
175 olds?.serviceConnectionTokenId ?? output?.serviceConnectionTokenId;
176 const nextId = news.serviceConnectionTokenId
177 ? rfc1035(news.serviceConnectionTokenId, "service-connection-token")
178 : previousId;
179 const previousLocation = normalizeLocation(
180 olds?.location ?? output?.location,
181 env.region,
182 );
183 const nextLocation = normalizeLocation(
184 news.location ?? olds?.location ?? output?.location,
185 env.region,
186 );
187 const previousNetwork = lastSegment(
188 canonicalizeLink(olds?.network ?? output?.network),
189 );
190 const nextNetwork = lastSegment(canonicalizeLink(news.network));
191 if (
192 (previousId !== undefined &&
193 nextId !== undefined &&
194 nextId !== previousId) ||
195 previousLocation !== nextLocation ||
196 (previousNetwork.length > 0 && previousNetwork !== nextNetwork)
197 ) {
198 return { action: "replace" as const };
199 }
200 return undefined;
201 }),
202
203 read: Effect.fn(function* ({ id, olds, output }) {
204 const env = yield* GcpEnvironment.current;
205 const serviceConnectionTokenId = yield* toPhysicalId(
206 id,
207 olds?.serviceConnectionTokenId,
208 output?.serviceConnectionTokenId,
209 "service-connection-token",
210 );
211 const location = normalizeLocation(
212 olds?.location ?? output?.location,
213 env.region,
214 );
215 const name =
216 output?.name ??
217 resourceName(env.project, location, serviceConnectionTokenId);
218 const existing = yield* getByName(name);
219 if (existing === undefined) return undefined;
220 const attrs = toAttrs(existing, env.project, env.region);
221 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
222 ? attrs
223 : Unowned(attrs);
224 }),
225
226 list: () =>
227 Effect.gen(function* () {
228 const env = yield* GcpEnvironment.current;
229 const items = yield* collectPages(
230 networkconnectivity.listProjectsLocationsServiceConnectionTokens.pages(
231 {
232 parent: parentOf(env.project, "-"),
233 pageSize: 1000,
234 },
235 ),
236 (page) => page.serviceConnectionTokens,
237 );
238 return items
239 .filter((item) => hasAlchemyLabelKeys(item.labels))
240 .map((item) => toAttrs(item, env.project, env.region));
241 }),
242
243 reconcile: Effect.fn(function* ({ id, news, output }) {
244 const env = yield* GcpEnvironment.current;
245 const serviceConnectionTokenId = yield* toPhysicalId(
246 id,
247 news.serviceConnectionTokenId,
248 output?.serviceConnectionTokenId,
249 "service-connection-token",
250 );
251 const location = normalizeLocation(
252 news.location ?? output?.location,
253 env.region,
254 );
255 const name = resourceName(
256 env.project,
257 location,
258 serviceConnectionTokenId,
259 );
260 const desiredLabels = {
261 ...toLabels(news.labels),
262 ...(yield* createInternalLabels(id)),
263 };
264 const network = toNetworkResource(env.project, news.network);
265
266 let current = yield* getByName(name);
267
268 if (current === undefined) {
269 const created = yield* networkconnectivity
270 .createProjectsLocationsServiceConnectionTokens({
271 parent: parentOf(env.project, location),
272 serviceConnectionTokenId,
273 body: {
274 network,
275 description: news.description,
276 labels: desiredLabels,
277 },
278 })
279 .pipe(
280 Effect.retry({
281 while: (error) => error._tag === "Conflict",
282 times: 5,
283 schedule: Schedule.spaced("2 seconds"),
284 }),
285 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
286 );
287 if (created !== undefined) {
288 yield* waitForOperation(created);
289 }
290 current = yield* waitUntilPresent(getByName(name), name);
291 }
292
293 if (current === undefined) {
294 return yield* new NetworkConnectivityNotResolved({ name });
295 }
296
297 return toAttrs(current, env.project, env.region);
298 }),
299
300 delete: Effect.fn(function* ({ output }) {
301 const operation = yield* networkconnectivity
302 .deleteProjectsLocationsServiceConnectionTokens({
303 name: output.name,
304 etag: output.etag,
305 })
306 .pipe(
307 Effect.retry({
308 while: (error) => error._tag === "Conflict",
309 times: 8,
310 schedule: Schedule.spaced("2 seconds"),
311 }),
312 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
313 );
314 if (operation !== undefined) {
315 yield* waitForOperation(operation, { notFoundOk: true });
316 }
317 yield* waitUntilGone(getByName(output.name), output.name);
318 }),
319 });