1import * as saasservicemgmt from "@distilled.cloud/gcp/saasservicemgmt_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 ResourceNotResolved,
19 collectPages,
20 expandName,
21 fieldMask,
22 fingerprint,
23 hasAlchemyLabelKeys,
24 lastSegment,
25 normalizeLocation,
26 parentOf,
27 parseName,
28 replaceOnIdentity,
29 resourceName,
30 sameRef,
31 toPhysicalId,
32 userAnnotations,
33 userLabels,
34 waitUntilGone,
35} from "./internal.ts";
36
37const COLLECTION = "rolloutKinds";
38
39export type RolloutKindUpdateUnitKindStrategy =
40 | saasservicemgmt.RolloutKindUpdateUnitKindStrategyEnum
41 | (string & {});
42
43export type RolloutKindErrorBudget = {
44 /** Max percent of units in a location allowed to fail without pausing. */
45 allowedPercentage?: number;
46 /** Max number of failed units in a location without pausing. */
47 allowedCount?: number;
48};
49
50export type RolloutKindUnitUpdatePacing = {
51 /** Max percent of units that may be in-flight in a region. */
52 maxConcurrentOperationsPercent?: { value?: string };
53 /** Absolute cap on concurrent unit operations. */
54 maxConcurrentOperationsCount?: number;
55};
56
57export type RolloutKindProps = {
58 /**
59 * RolloutKind id (the `{rollout_kind_id}` segment of
60 * `projects/{project}/locations/{location}/rolloutKinds/{rollout_kind_id}`).
61 * If omitted, a unique RFC1035 name is generated. Immutable —
62 * changing it replaces the rollout kind.
63 */
64 rolloutKindId?: string;
65 /**
66 * Region of the rollout kind (`us-central1`, …). Immutable — changing
67 * it replaces the rollout kind. `US-CENTRAL1` is accepted and
68 * normalized to `us-central1`.
69 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
70 */
71 location?: string;
72 /**
73 * UnitKind whose units this rollout kind targets. Accepts a unit kind
74 * id or a full resource name. Immutable — changing it replaces the
75 * rollout kind.
76 */
77 unitKind: string;
78 /**
79 * Orchestration strategy, e.g. `Google.Cloud.Simple.AllAtOnce` or
80 * `Google.Cloud.Simple.OneLocationAtATime`.
81 */
82 rolloutOrchestrationStrategy?: string;
83 /**
84 * When to update the unit kind (`UPDATE_UNIT_KIND_STRATEGY_ON_START`
85 * or `UPDATE_UNIT_KIND_STRATEGY_NEVER`).
86 */
87 updateUnitKindStrategy?: RolloutKindUpdateUnitKindStrategy;
88 /**
89 * Pause the rollout if failures exceed this budget.
90 */
91 errorBudget?: RolloutKindErrorBudget;
92 /**
93 * CEL filter reducing the eligible unit population.
94 */
95 unitFilter?: string;
96 /**
97 * Cap on concurrent unit operations per region.
98 */
99 unitUpdatePacing?: RolloutKindUnitUpdatePacing;
100 /**
101 * User labels. Alchemy ownership labels are merged in automatically.
102 */
103 labels?: Record<string, string>;
104 /**
105 * Unstructured annotations preserved across updates.
106 */
107 annotations?: Record<string, string>;
108};
109
110export type RolloutKind = Resource<
111 "GCP.SaasServiceManagement.RolloutKind",
112 RolloutKindProps,
113 {
114 /** Full resource name `projects/{project}/locations/{location}/rolloutKinds/{rollout_kind_id}`. */
115 name: string;
116 /** RolloutKind id (last path segment). */
117 rolloutKindId: string;
118 /** Project id. */
119 project: string;
120 /** Location id. */
121 location: string;
122 /** UnitKind resource name. */
123 unitKind: string | undefined;
124 /** UnitKind id (last path segment). */
125 unitKindId: string | undefined;
126 /** Orchestration strategy. */
127 rolloutOrchestrationStrategy: string | undefined;
128 /** Unit-kind update strategy. */
129 updateUnitKindStrategy: string | undefined;
130 /** Error budget. */
131 errorBudget: RolloutKindErrorBudget | undefined;
132 /** CEL unit filter. */
133 unitFilter: string | undefined;
134 /** Concurrent-operation pacing. */
135 unitUpdatePacing: RolloutKindUnitUpdatePacing | undefined;
136 /** User labels (Alchemy ownership labels stripped). */
137 labels: Record<string, string>;
138 /** User annotations. */
139 annotations: Record<string, string>;
140 /** Server UUID. */
141 uid: string | undefined;
142 /** Server etag. */
143 etag: string | undefined;
144 /** RFC3339 creation timestamp. */
145 createTime: string | undefined;
146 /** RFC3339 last-update timestamp. */
147 updateTime: string | undefined;
148 },
149 never,
150 Providers
151>;
152
153/**
154 * An App Lifecycle Manager rollout kind — the policy a Rollout follows
155 * when upgrading units of a UnitKind.
156 *
157 * `rolloutKindId`, `location`, and `unitKind` replace the rollout kind.
158 * Strategy, error budget, unit filter, pacing, labels, and annotations
159 * update in place.
160 *
161 * ### Creating a RolloutKind
162 * **Example:** All-at-once
163 * ```typescript
164 * const kind = yield* GCP.SaasServiceManagement.RolloutKind("Wave", {
165 * unitKind: storeKind.name,
166 * rolloutOrchestrationStrategy: "Google.Cloud.Simple.AllAtOnce",
167 * labels: { env: "prod" },
168 * });
169 * ```
170 *
171 * ### Updating a RolloutKind
172 * **Example:** Tighten the error budget
173 * ```typescript
174 * const kind = yield* GCP.SaasServiceManagement.RolloutKind("Wave", {
175 * rolloutKindId: kind.rolloutKindId,
176 * unitKind: storeKind.name,
177 * errorBudget: { allowedCount: 1, allowedPercentage: 5 },
178 * });
179 * ```
180 *
181 * @resource
182 * @category SaasServiceManagement
183 */
184export const RolloutKind = Resource<RolloutKind>(
185 "GCP.SaasServiceManagement.RolloutKind",
186);
187
188const toAttrs = (item: saasservicemgmt.RolloutKind, project: string) => {
189 const name = item.name ?? "";
190 const parsed = parseName(name, COLLECTION);
191 return {
192 name,
193 rolloutKindId: parsed.id,
194 project: parsed.project || project,
195 location: parsed.location,
196 unitKind: item.unitKind,
197 unitKindId: item.unitKind ? lastSegment(item.unitKind) : undefined,
198 rolloutOrchestrationStrategy: item.rolloutOrchestrationStrategy,
199 updateUnitKindStrategy: item.updateUnitKindStrategy,
200 errorBudget: item.errorBudget
201 ? {
202 allowedPercentage: item.errorBudget.allowedPercentage,
203 allowedCount: item.errorBudget.allowedCount,
204 }
205 : undefined,
206 unitFilter: item.unitFilter,
207 unitUpdatePacing: item.unitUpdatePacing
208 ? {
209 maxConcurrentOperationsPercent:
210 item.unitUpdatePacing.maxConcurrentOperationsPercent,
211 maxConcurrentOperationsCount:
212 item.unitUpdatePacing.maxConcurrentOperationsCount,
213 }
214 : undefined,
215 labels: userLabels(item.labels),
216 annotations: userAnnotations(item.annotations),
217 uid: item.uid,
218 etag: item.etag,
219 createTime: item.createTime,
220 updateTime: item.updateTime,
221 };
222};
223
224const getByName = (name: string) =>
225 name.length === 0
226 ? Effect.succeed(undefined)
227 : saasservicemgmt
228 .getProjectsLocationsRolloutKinds({ name })
229 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
230
231const listOwned = (project: string, location: string) =>
232 collectPages(
233 saasservicemgmt.listProjectsLocationsRolloutKinds.pages({
234 parent: parentOf(project, "-"),
235 pageSize: 1000,
236 }),
237 (page) => page.rolloutKinds,
238 ).pipe(
239 Effect.map((items) =>
240 items.filter((item) => hasAlchemyLabelKeys(item.labels)),
241 ),
242 Effect.flatMap((items) =>
243 items.length > 0
244 ? Effect.succeed(items)
245 : collectPages(
246 saasservicemgmt.listProjectsLocationsRolloutKinds.pages({
247 parent: parentOf(project, location),
248 pageSize: 1000,
249 }),
250 (page) => page.rolloutKinds,
251 ).pipe(
252 Effect.map((fallback) =>
253 fallback.filter((item) => hasAlchemyLabelKeys(item.labels)),
254 ),
255 ),
256 ),
257 );
258
259export const RolloutKindProvider = () =>
260 Provider.succeed(RolloutKind, {
261 stables: [
262 "name",
263 "rolloutKindId",
264 "project",
265 "location",
266 "unitKindId",
267 "uid",
268 "createTime",
269 ],
270
271 diff: Effect.fn(function* ({ news, olds, output }) {
272 if (!isResolved(news)) return undefined;
273 const env = yield* GcpEnvironment.current;
274 return replaceOnIdentity({
275 previousId: olds?.rolloutKindId ?? output?.rolloutKindId,
276 nextId:
277 news.rolloutKindId ?? olds?.rolloutKindId ?? output?.rolloutKindId,
278 previousLocation: normalizeLocation(
279 olds?.location ?? output?.location ?? env.region,
280 ),
281 nextLocation: normalizeLocation(
282 news.location ?? olds?.location ?? output?.location ?? env.region,
283 ),
284 extra: !sameRef(olds?.unitKind ?? output?.unitKind, news.unitKind),
285 });
286 }),
287
288 read: Effect.fn(function* ({ id, olds, output }) {
289 const env = yield* GcpEnvironment.current;
290 const rolloutKindId = yield* toPhysicalId(
291 id,
292 olds?.rolloutKindId,
293 output?.rolloutKindId,
294 "rk",
295 );
296 const location = normalizeLocation(
297 olds?.location ?? output?.location ?? env.region,
298 );
299 const name =
300 output?.name ??
301 resourceName(env.project, location, COLLECTION, rolloutKindId);
302 const existing = yield* getByName(name);
303 if (existing === undefined) return undefined;
304 const attrs = toAttrs(existing, env.project);
305 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
306 ? attrs
307 : Unowned(attrs);
308 }),
309
310 list: () =>
311 Effect.gen(function* () {
312 const env = yield* GcpEnvironment.current;
313 const items = yield* listOwned(env.project, env.region);
314 return items.map((item) => toAttrs(item, env.project));
315 }),
316
317 reconcile: Effect.fn(function* ({ id, news, output }) {
318 const env = yield* GcpEnvironment.current;
319 const rolloutKindId = yield* toPhysicalId(
320 id,
321 news.rolloutKindId,
322 output?.rolloutKindId,
323 "rk",
324 );
325 const location = normalizeLocation(
326 news.location ?? output?.location ?? env.region,
327 );
328 const name = resourceName(
329 env.project,
330 location,
331 COLLECTION,
332 rolloutKindId,
333 );
334 const desiredLabels = {
335 ...toLabels(news.labels),
336 ...(yield* createInternalLabels(id)),
337 };
338 const unitKind = expandName(
339 news.unitKind,
340 env.project,
341 location,
342 "unitKinds",
343 );
344 const annotations = news.annotations;
345
346 let current = yield* getByName(output?.name ?? name);
347
348 if (current === undefined) {
349 const created = yield* saasservicemgmt
350 .createProjectsLocationsRolloutKinds({
351 parent: parentOf(env.project, location),
352 rolloutKindId,
353 body: {
354 unitKind,
355 rolloutOrchestrationStrategy: news.rolloutOrchestrationStrategy,
356 updateUnitKindStrategy: news.updateUnitKindStrategy,
357 errorBudget: news.errorBudget,
358 unitFilter: news.unitFilter,
359 unitUpdatePacing: news.unitUpdatePacing,
360 labels: desiredLabels,
361 annotations,
362 },
363 })
364 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
365 current = created ?? undefined;
366 }
367
368 if (current === undefined) {
369 return yield* new ResourceNotResolved({ name });
370 }
371
372 const observedLabels = tagRecord(current.labels);
373 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
374 const labelsChanged = upsert.length > 0 || removed.length > 0;
375 const annotationsChanged =
376 annotations !== undefined &&
377 fingerprint(userAnnotations(current.annotations)) !==
378 fingerprint(annotations);
379 const strategyChanged =
380 news.rolloutOrchestrationStrategy !== undefined &&
381 (current.rolloutOrchestrationStrategy ?? "") !==
382 news.rolloutOrchestrationStrategy;
383 const updateStrategyChanged =
384 news.updateUnitKindStrategy !== undefined &&
385 (current.updateUnitKindStrategy ?? "") !== news.updateUnitKindStrategy;
386 const budgetChanged =
387 news.errorBudget !== undefined &&
388 fingerprint(current.errorBudget) !== fingerprint(news.errorBudget);
389 const filterChanged =
390 news.unitFilter !== undefined &&
391 (current.unitFilter ?? "") !== news.unitFilter;
392 const pacingChanged =
393 news.unitUpdatePacing !== undefined &&
394 fingerprint(current.unitUpdatePacing) !==
395 fingerprint(news.unitUpdatePacing);
396 const mask = fieldMask([
397 labelsChanged && "labels",
398 annotationsChanged && "annotations",
399 strategyChanged && "rolloutOrchestrationStrategy",
400 updateStrategyChanged && "updateUnitKindStrategy",
401 budgetChanged && "errorBudget",
402 filterChanged && "unitFilter",
403 pacingChanged && "unitUpdatePacing",
404 ]);
405
406 if (mask.length > 0) {
407 current = yield* saasservicemgmt.patchProjectsLocationsRolloutKinds({
408 name: current.name ?? name,
409 updateMask: mask,
410 body: {
411 name: current.name ?? name,
412 etag: current.etag,
413 rolloutOrchestrationStrategy: news.rolloutOrchestrationStrategy,
414 updateUnitKindStrategy: news.updateUnitKindStrategy,
415 errorBudget: news.errorBudget,
416 unitFilter: news.unitFilter,
417 unitUpdatePacing: news.unitUpdatePacing,
418 labels: desiredLabels,
419 annotations,
420 },
421 });
422 }
423
424 return toAttrs(current, env.project);
425 }),
426
427 delete: Effect.fn(function* ({ output }) {
428 yield* saasservicemgmt
429 .deleteProjectsLocationsRolloutKinds({ name: output.name })
430 .pipe(
431 Effect.retry({
432 while: (error) => error._tag === "Conflict",
433 times: 8,
434 schedule: Schedule.spaced("2 seconds"),
435 }),
436 Effect.catchTag("NotFound", () => Effect.void),
437 );
438 yield* waitUntilGone(getByName(output.name), output.name);
439 }),
440 });