1import * as gkehub from "@distilled.cloud/gcp/gkehub_v2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 fieldMask,
20 fingerprint,
21 listLabeledPages,
22 membershipName,
23 normalizeLocation,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 toPhysicalId,
28 userLabels,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32} from "./internal.ts";
33
34export type FeatureSpec = gkehub.FeatureSpec;
35export type FeatureState = gkehub.FeatureState;
36
37export type MembershipsFeatureProps = {
38 /**
39 * Parent Fleet membership. Full name
40 * `projects/{project}/locations/{location}/memberships/{membership}`
41 * or the membership id (combined with `location`). Immutable —
42 * changing it replaces the feature.
43 */
44 membership: string;
45 /**
46 * Location of the parent membership (`global`, `us-central1`, …).
47 * Used when `membership` is a bare id. Immutable — changing it
48 * replaces the feature. Fleet memberships are typically `global`.
49 * @default "global"
50 */
51 location?: string;
52 /**
53 * Feature id (the `{feature}` segment of
54 * `.../memberships/{membership}/features/{feature}`). Well-known Fleet
55 * Feature names such as `configmanagement`, `servicemesh`, or
56 * `policycontroller`. If omitted, a unique RFC1035 name is generated.
57 * Immutable — changing it replaces the feature.
58 */
59 featureId?: string;
60 /**
61 * Per-feature spec for this membership (Config Management, Service
62 * Mesh, Policy Controller, …).
63 */
64 spec?: FeatureSpec;
65 /**
66 * User labels. Alchemy ownership labels are merged in automatically.
67 */
68 labels?: Record<string, string>;
69};
70
71export type MembershipsFeature = Resource<
72 "GCP.GKEHub.MembershipsFeature",
73 MembershipsFeatureProps,
74 {
75 /** Full resource name. */
76 name: string;
77 /** Feature id (last path segment). */
78 featureId: string;
79 /** Parent membership resource name. */
80 membership: string;
81 /** Membership id (last path segment of the parent). */
82 membershipId: string;
83 /** Project id. */
84 project: string;
85 /** Location id. */
86 location: string;
87 /** Per-feature spec. */
88 spec: FeatureSpec | undefined;
89 /** User labels (Alchemy ownership labels stripped). */
90 labels: Record<string, string>;
91 /** Lifecycle of the MembershipFeature resource. */
92 lifecycleState: string | undefined;
93 /** High-level Feature state. */
94 state: FeatureState | undefined;
95 /** RFC3339 creation timestamp. */
96 createTime: string | undefined;
97 /** RFC3339 last-update timestamp. */
98 updateTime: string | undefined;
99 /** RFC3339 deletion timestamp. */
100 deleteTime: string | undefined;
101 },
102 never,
103 Providers
104>;
105
106/**
107 * A GKE Hub MembershipFeature — the per-membership settings for a Fleet
108 * Feature such as Config Management or Service Mesh.
109 *
110 * Changing `featureId`, `membership`, or `location` replaces the
111 * feature. Spec and labels update in place.
112 *
113 * ### Creating a Membership Feature
114 * **Example:** Config Management on a membership
115 * ```typescript
116 * const feature = yield* GCP.GKEHub.MembershipsFeature("ConfigSync", {
117 * membership: "projects/my-project/locations/global/memberships/app",
118 * featureId: "configmanagement",
119 * spec: {
120 * configmanagement: {
121 * configSync: { enabled: true, sourceFormat: "unstructured" },
122 * },
123 * },
124 * labels: { env: "prod" },
125 * });
126 * ```
127 *
128 * **Example:** Service Mesh
129 * ```typescript
130 * const feature = yield* GCP.GKEHub.MembershipsFeature("Mesh", {
131 * membership: membership.name,
132 * featureId: "servicemesh",
133 * spec: {
134 * servicemesh: { management: "MANAGEMENT_AUTOMATIC" },
135 * },
136 * });
137 * ```
138 *
139 * ### Updating a Membership Feature
140 * **Example:** Spec and labels
141 * ```typescript
142 * // Same logical id, changed props: updated in place.
143 * const feature = yield* GCP.GKEHub.MembershipsFeature("ConfigSync", {
144 * membership: membership.name,
145 * featureId: "configmanagement",
146 * spec: {
147 * configmanagement: {
148 * configSync: { enabled: true, preventDrift: true },
149 * },
150 * },
151 * labels: { env: "prod", team: "platform" },
152 * });
153 * ```
154 *
155 * @resource
156 * @category GKEHub
157 */
158export const MembershipsFeature = Resource<MembershipsFeature>(
159 "GCP.GKEHub.MembershipsFeature",
160);
161
162const resourceName = (membership: string, featureId: string) =>
163 `${membership}/features/${featureId}`;
164
165const toAttrs = (item: gkehub.MembershipFeature, project: string) => {
166 const name = item.name ?? "";
167 const parsed = parseName(name, "features");
168 return {
169 name,
170 featureId: parsed.id,
171 membership: parsed.parent,
172 membershipId: parsed.membershipId,
173 project: parsed.project || project,
174 location: parsed.location,
175 spec: item.spec,
176 labels: userLabels(item.labels),
177 lifecycleState: item.lifecycleState?.state,
178 state: item.state,
179 createTime: item.createTime,
180 updateTime: item.updateTime,
181 deleteTime: item.deleteTime,
182 };
183};
184
185const getByName = (name: string) =>
186 gkehub
187 .getProjectsLocationsMembershipsFeatures({ name })
188 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
189
190const listChildren = (parent: string) =>
191 listLabeledPages(
192 gkehub.listProjectsLocationsMembershipsFeatures.pages({
193 parent,
194 pageSize: 1000,
195 }),
196 (page) => page.membershipFeatures,
197 (item) => item.labels,
198 );
199
200const listOwned = (project: string) =>
201 gkehub.listProjectsLocations
202 .pages({ name: `projects/${project}`, pageSize: 100 })
203 .pipe(
204 Stream.flatMap((page) => Stream.fromIterable(page.locations ?? [])),
205 Stream.map((location) => location.locationId),
206 Stream.filter(
207 (locationId): locationId is string =>
208 locationId !== undefined && locationId.length > 0,
209 ),
210 Stream.runCollect,
211 Effect.flatMap((locations) =>
212 Effect.forEach(
213 Array.from(locations),
214 (location) =>
215 listChildren(
216 `projects/${project}/locations/${location}/memberships/-`,
217 ),
218 { concurrency: 4 },
219 ),
220 ),
221 Effect.map((pages) => pages.flat()),
222 );
223
224export const MembershipsFeatureProvider = () =>
225 Provider.succeed(MembershipsFeature, {
226 stables: [
227 "name",
228 "featureId",
229 "membership",
230 "membershipId",
231 "project",
232 "location",
233 "createTime",
234 ],
235
236 diff: Effect.fn(function* ({ news, olds, output }) {
237 if (!isResolved(news)) return undefined;
238 const env = yield* GcpEnvironment.current;
239 const location = normalizeLocation(
240 news.location ?? olds?.location ?? output?.location,
241 );
242 return replaceOnIdentity({
243 previousId: olds?.featureId ?? output?.featureId,
244 nextId: news.featureId ?? olds?.featureId ?? output?.featureId,
245 previousLocation: normalizeLocation(olds?.location ?? output?.location),
246 nextLocation: location,
247 previousParent: olds?.membership ?? output?.membership,
248 nextParent: membershipName(news.membership, env.project, location),
249 });
250 }),
251
252 read: Effect.fn(function* ({ id, olds, output }) {
253 const env = yield* GcpEnvironment.current;
254 const featureId = yield* toPhysicalId(
255 id,
256 olds?.featureId,
257 output?.featureId,
258 "feature",
259 );
260 const location = normalizeLocation(olds?.location ?? output?.location);
261 const membership = membershipName(
262 olds?.membership ?? output?.membership ?? "",
263 env.project,
264 location,
265 );
266 const name = output?.name ?? resourceName(membership, featureId);
267 const existing = yield* getByName(name);
268 if (existing === undefined) return undefined;
269 const attrs = toAttrs(existing, env.project);
270 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
271 ? attrs
272 : Unowned(attrs);
273 }),
274
275 list: () =>
276 Effect.gen(function* () {
277 const env = yield* GcpEnvironment.current;
278 const items = yield* listOwned(env.project);
279 return items.map((item) => toAttrs(item, env.project));
280 }),
281
282 reconcile: Effect.fn(function* ({ id, news, output }) {
283 const env = yield* GcpEnvironment.current;
284 const featureId = yield* toPhysicalId(
285 id,
286 news.featureId,
287 output?.featureId,
288 "feature",
289 );
290 const location = normalizeLocation(news.location ?? output?.location);
291 const membership = membershipName(news.membership, env.project, location);
292 const name = resourceName(membership, featureId);
293 const desiredLabels = {
294 ...toLabels(news.labels),
295 ...(yield* createInternalLabels(id)),
296 };
297
298 let current = yield* getByName(output?.name ?? name);
299
300 if (current === undefined) {
301 const created = yield* gkehub
302 .createProjectsLocationsMembershipsFeatures({
303 parent: membership,
304 featureId,
305 body: {
306 labels: desiredLabels,
307 spec: news.spec,
308 },
309 })
310 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
311 if (created !== undefined) {
312 // ALREADY_EXISTS (6): a concurrent create won the race.
313 yield* waitForOperation(created).pipe(
314 Effect.catchTag("GCP.OperationFailed", (error) =>
315 error.code === 6 ? Effect.void : Effect.fail(error),
316 ),
317 );
318 }
319 current = yield* waitUntilExists(getByName(name), name);
320 }
321
322 if (current === undefined) {
323 return yield* new ResourceNotResolved({ name });
324 }
325
326 const observedLabels = tagRecord(current.labels);
327 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
328 const specChanged =
329 news.spec !== undefined &&
330 fingerprint(current.spec) !== fingerprint(news.spec);
331 const mask = fieldMask([
332 (upsert.length > 0 || removed.length > 0) && "labels",
333 specChanged && "spec",
334 ]);
335
336 if (mask.length > 0) {
337 const operation =
338 yield* gkehub.patchProjectsLocationsMembershipsFeatures({
339 name: current.name ?? name,
340 updateMask: mask,
341 body: {
342 labels: desiredLabels,
343 spec: news.spec,
344 },
345 });
346 yield* waitForOperation(operation);
347 current = yield* waitUntilExists(
348 getByName(current.name ?? name),
349 current.name ?? name,
350 );
351 }
352
353 return toAttrs(current, env.project);
354 }),
355
356 delete: Effect.fn(function* ({ output }) {
357 const operation = yield* gkehub
358 .deleteProjectsLocationsMembershipsFeatures({
359 name: output.name,
360 })
361 .pipe(
362 Effect.retry({
363 while: (error) => error._tag === "Conflict",
364 times: 8,
365 schedule: Schedule.spaced("2 seconds"),
366 }),
367 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
368 );
369 if (operation !== undefined) {
370 // NOT_FOUND (5): the feature was already gone.
371 yield* waitForOperation(operation).pipe(
372 Effect.catchTag("GCP.OperationFailed", (error) =>
373 error.code === 5 ? Effect.void : Effect.fail(error),
374 ),
375 );
376 }
377 yield* waitUntilGone(getByName(output.name), output.name);
378 }),
379 });