1import * as networkservices from "@distilled.cloud/gcp/networkservices_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 normalizeLocation,
23 parentOf,
24 parseName,
25 resourceName,
26 rfc1035,
27 toPhysicalId,
28 userLabels,
29 waitForOperation,
30 waitUntilGone,
31 waitUntilPresent,
32} from "./internal.ts";
33
34const COLLECTION = "meshes";
35
36export type MeshEnvoyHeaders =
37 | networkservices.MeshEnvoyHeadersEnum
38 | (string & {});
39
40export type MeshProps = {
41 /**
42 * Mesh id (the `{mesh}` segment of
43 * `projects/{project}/locations/{location}/meshes/{mesh}`). If omitted,
44 * a unique name is generated from the stack, stage, and logical id.
45 * Must be 1-63 characters and not start with a number. Immutable —
46 * changing it replaces the mesh.
47 */
48 meshId?: string;
49 /**
50 * Location (`global`, `us-central1`, …). Meshes are typically
51 * `global`. Immutable — changing it replaces the mesh. `US-CENTRAL1`
52 * is accepted and normalized to `us-central1`.
53 * @default "global"
54 */
55 location?: string;
56 /**
57 * Human-readable description. Max 1024 characters.
58 */
59 description?: string;
60 /**
61 * Localhost port the sidecar proxy intercepts (1-65535). Unset uses
62 * `15001`. Sidecar deployments only.
63 */
64 interceptionPort?: number;
65 /**
66 * Whether Envoy inserts debug headers on upstream requests.
67 * `NONE` (default) or `DEBUG_HEADERS`.
68 */
69 envoyHeaders?: MeshEnvoyHeaders;
70 /**
71 * User labels. Alchemy ownership labels are merged in automatically.
72 */
73 labels?: Record<string, string>;
74};
75
76export type Mesh = Resource<
77 "GCP.NetworkServices.Mesh",
78 MeshProps,
79 {
80 /** Full resource name `projects/{project}/locations/{location}/meshes/{mesh}`. */
81 name: string;
82 /** Mesh id (last path segment). */
83 meshId: string;
84 /** Project id. */
85 project: string;
86 /** Location id (`global`, `us-central1`, …). */
87 location: string;
88 /** User-provided description. */
89 description: string | undefined;
90 /** Sidecar interception port, if set. */
91 interceptionPort: number | undefined;
92 /** Envoy debug-header insertion mode. */
93 envoyHeaders: string | undefined;
94 /** User labels (Alchemy ownership labels stripped). */
95 labels: Record<string, string>;
96 /** Server-defined resource URL. */
97 selfLink: string | undefined;
98 /** RFC3339 creation timestamp. */
99 createTime: string | undefined;
100 /** RFC3339 last-update timestamp. */
101 updateTime: string | undefined;
102 },
103 never,
104 Providers
105>;
106
107/**
108 * A Cloud Service Mesh configuration grouping for workload-to-workload
109 * traffic. Routes attached to the mesh decide how requests are routed
110 * inside this logical boundary.
111 *
112 * Changing `meshId` or `location` replaces the mesh. Description,
113 * labels, interception port, and Envoy header settings update in place.
114 *
115 * ### Creating a Mesh
116 * **Example:** Generated name
117 * ```typescript
118 * const mesh = yield* GCP.NetworkServices.Mesh("Sidecar", {});
119 * ```
120 *
121 * **Example:** Named mesh with interception port
122 * ```typescript
123 * const mesh = yield* GCP.NetworkServices.Mesh("Sidecar", {
124 * meshId: "app-mesh",
125 * description: "prod sidecar mesh",
126 * interceptionPort: 15001,
127 * labels: { env: "prod" },
128 * });
129 * ```
130 *
131 * @resource
132 * @category NetworkServices
133 */
134export const Mesh = Resource<Mesh>("GCP.NetworkServices.Mesh");
135
136const toAttrs = (mesh: networkservices.Mesh, project: string) => {
137 const name = mesh.name ?? "";
138 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
139 return {
140 name,
141 meshId: parsed.id,
142 project: parsed.project || project,
143 location: parsed.location || DEFAULT_GLOBAL,
144 description: mesh.description,
145 interceptionPort: mesh.interceptionPort,
146 envoyHeaders: mesh.envoyHeaders,
147 labels: userLabels(mesh.labels),
148 selfLink: mesh.selfLink,
149 createTime: mesh.createTime,
150 updateTime: mesh.updateTime,
151 };
152};
153
154const getByName = (name: string) =>
155 networkservices
156 .getProjectsLocationsMeshes({ name })
157 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
158
159export const MeshProvider = () =>
160 Provider.succeed(Mesh, {
161 stables: [
162 "name",
163 "meshId",
164 "project",
165 "location",
166 "createTime",
167 "selfLink",
168 ],
169
170 diff: Effect.fn(function* ({ news, olds, output }) {
171 if (!isResolved(news)) return undefined;
172 const previousId = olds?.meshId ?? output?.meshId;
173 const nextId = news.meshId ? rfc1035(news.meshId, "mesh") : previousId;
174 const previousLocation = normalizeLocation(
175 olds?.location ?? output?.location,
176 DEFAULT_GLOBAL,
177 );
178 const nextLocation = normalizeLocation(
179 news.location ?? olds?.location ?? output?.location,
180 DEFAULT_GLOBAL,
181 );
182 if (
183 (previousId !== undefined &&
184 nextId !== undefined &&
185 nextId !== previousId) ||
186 previousLocation !== nextLocation
187 ) {
188 return { action: "replace" as const };
189 }
190 return undefined;
191 }),
192
193 read: Effect.fn(function* ({ id, olds, output }) {
194 const env = yield* GcpEnvironment.current;
195 const meshId = yield* toPhysicalId(
196 id,
197 olds?.meshId,
198 output?.meshId,
199 "mesh",
200 );
201 const location = normalizeLocation(
202 olds?.location ?? output?.location,
203 DEFAULT_GLOBAL,
204 );
205 const name =
206 output?.name ?? resourceName(env.project, location, COLLECTION, meshId);
207 const existing = yield* getByName(name);
208 if (existing === undefined) return undefined;
209 const attrs = toAttrs(existing, env.project);
210 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
211 ? attrs
212 : Unowned(attrs);
213 }),
214
215 list: () =>
216 Effect.gen(function* () {
217 const env = yield* GcpEnvironment.current;
218 const items = yield* collectPages(
219 networkservices.listProjectsLocationsMeshes.pages({
220 parent: parentOf(env.project, "-"),
221 pageSize: 1000,
222 returnPartialSuccess: true,
223 }),
224 (page) => page.meshes,
225 );
226 return items
227 .filter((item) => hasAlchemyLabelKeys(item.labels))
228 .map((item) => toAttrs(item, env.project));
229 }),
230
231 reconcile: Effect.fn(function* ({ id, news, output }) {
232 const env = yield* GcpEnvironment.current;
233 const meshId = yield* toPhysicalId(
234 id,
235 news.meshId,
236 output?.meshId,
237 "mesh",
238 );
239 const location = normalizeLocation(
240 news.location ?? output?.location,
241 DEFAULT_GLOBAL,
242 );
243 const name = resourceName(env.project, location, COLLECTION, meshId);
244 const desiredLabels = {
245 ...toLabels(news.labels),
246 ...(yield* createInternalLabels(id)),
247 };
248
249 let current = yield* getByName(name);
250
251 if (current === undefined) {
252 const created = yield* networkservices
253 .createProjectsLocationsMeshes({
254 parent: parentOf(env.project, location),
255 meshId,
256 body: {
257 description: news.description,
258 labels: desiredLabels,
259 interceptionPort: news.interceptionPort,
260 envoyHeaders: news.envoyHeaders,
261 },
262 })
263 .pipe(
264 Effect.retry({
265 while: (error) => error._tag === "Conflict",
266 times: 5,
267 schedule: Schedule.spaced("2 seconds"),
268 }),
269 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
270 );
271 if (created !== undefined) {
272 yield* waitForOperation(created);
273 }
274 current = yield* waitUntilPresent(getByName(name), name);
275 }
276
277 const observedLabels = tagRecord(current.labels);
278 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
279 const labelsChanged = upsert.length > 0 || removed.length > 0;
280 const updateMask = changedFields([
281 ["labels", labelsChanged],
282 [
283 "description",
284 (current.description ?? "") !== (news.description ?? ""),
285 ],
286 [
287 "interceptionPort",
288 (current.interceptionPort ?? 0) !== (news.interceptionPort ?? 0),
289 ],
290 [
291 "envoyHeaders",
292 (current.envoyHeaders ?? "") !== (news.envoyHeaders ?? ""),
293 ],
294 ]);
295
296 if (updateMask.length > 0) {
297 const operation = yield* networkservices.patchProjectsLocationsMeshes({
298 name: current.name ?? name,
299 updateMask: updateMask.join(","),
300 body: {
301 name: current.name ?? name,
302 labels: desiredLabels,
303 description: news.description,
304 interceptionPort: news.interceptionPort,
305 envoyHeaders: news.envoyHeaders,
306 },
307 });
308 yield* waitForOperation(operation);
309 current = yield* waitUntilPresent(
310 getByName(current.name ?? name),
311 current.name ?? name,
312 );
313 }
314
315 return toAttrs(current, env.project);
316 }),
317
318 delete: Effect.fn(function* ({ output }) {
319 const operation = yield* networkservices
320 .deleteProjectsLocationsMeshes({ name: output.name })
321 .pipe(
322 Effect.retry({
323 while: (error) => error._tag === "Conflict",
324 times: 8,
325 schedule: Schedule.spaced("2 seconds"),
326 }),
327 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
328 );
329 if (operation !== undefined) {
330 yield* waitForOperation(operation, { notFoundOk: true });
331 }
332 yield* waitUntilGone(getByName(output.name), output.name);
333 }),
334 });