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 = "rollouts";
38
39export type RolloutControlAction =
40 | saasservicemgmt.RolloutControlActionEnum
41 | (string & {});
42
43export type RolloutControl = {
44 action?: RolloutControlAction;
45 runParams?: { retryFailedOperations?: boolean };
46};
47
48export type RolloutStats = {
49 operationsByState?: Array<{ count?: number; group?: string }>;
50 estimatedTotalUnitCount?: string;
51};
52
53export type RolloutProps = {
54 /**
55 * Rollout id (the `{rollout_id}` segment of
56 * `projects/{project}/locations/{location}/rollouts/{rollout_id}`). If
57 * omitted, a unique RFC1035 name is generated. Immutable — changing
58 * it replaces the rollout.
59 */
60 rolloutId?: string;
61 /**
62 * Region of the rollout (`us-central1`, …). Immutable — changing it
63 * replaces the rollout. `US-CENTRAL1` is accepted and normalized to
64 * `us-central1`.
65 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
66 */
67 location?: string;
68 /**
69 * RolloutKind this rollout follows. Accepts a rollout kind id or a
70 * full resource name. Immutable — changing it replaces the rollout.
71 */
72 rolloutKind?: string;
73 /**
74 * Release rolled out to target units. Mutually exclusive with
75 * `flagRelease`. Immutable — changing it replaces the rollout.
76 */
77 release?: string;
78 /**
79 * Flag release rolled out to target units. Mutually exclusive with
80 * `release`. Immutable — changing it replaces the rollout.
81 */
82 flagRelease?: string;
83 /**
84 * CEL filter reducing the eligible unit population for this rollout.
85 */
86 unitFilter?: string;
87 /**
88 * Orchestration strategy override. If omitted, the RolloutKind
89 * strategy is used.
90 */
91 rolloutOrchestrationStrategy?: string;
92 /**
93 * Requested execution change (`ROLLOUT_ACTION_RUN`,
94 * `ROLLOUT_ACTION_PAUSE`, `ROLLOUT_ACTION_CANCEL`).
95 */
96 control?: RolloutControl;
97 /**
98 * User labels. Alchemy ownership labels are merged in automatically.
99 */
100 labels?: Record<string, string>;
101 /**
102 * Unstructured annotations preserved across updates.
103 */
104 annotations?: Record<string, string>;
105};
106
107export type Rollout = Resource<
108 "GCP.SaasServiceManagement.Rollout",
109 RolloutProps,
110 {
111 /** Full resource name `projects/{project}/locations/{location}/rollouts/{rollout_id}`. */
112 name: string;
113 /** Rollout id (last path segment). */
114 rolloutId: string;
115 /** Project id. */
116 project: string;
117 /** Location id. */
118 location: string;
119 /** RolloutKind resource name. */
120 rolloutKind: string | undefined;
121 /** RolloutKind id (last path segment). */
122 rolloutKindId: string | undefined;
123 /** Release resource name. */
124 release: string | undefined;
125 /** Release id (last path segment). */
126 releaseId: string | undefined;
127 /** Flag release resource name. */
128 flagRelease: string | undefined;
129 /** CEL unit filter. */
130 unitFilter: string | undefined;
131 /** Effective unit filter snapshotted at start. */
132 effectiveUnitFilter: string | undefined;
133 /** Orchestration strategy. */
134 rolloutOrchestrationStrategy: string | undefined;
135 /** Requested control action. */
136 control: RolloutControl | undefined;
137 /** Lifecycle state. */
138 state: string | undefined;
139 /** Human-readable last-transition message. */
140 stateMessage: string | undefined;
141 /** Progress stats. */
142 stats: RolloutStats | undefined;
143 /** Root rollout this one stems from. */
144 rootRollout: string | undefined;
145 /** Direct parent rollout. */
146 parentRollout: string | undefined;
147 /** User labels (Alchemy ownership labels stripped). */
148 labels: Record<string, string>;
149 /** User annotations. */
150 annotations: Record<string, string>;
151 /** Server UUID. */
152 uid: string | undefined;
153 /** Server etag. */
154 etag: string | undefined;
155 /** RFC3339 creation timestamp. */
156 createTime: string | undefined;
157 /** RFC3339 last-update timestamp. */
158 updateTime: string | undefined;
159 /** RFC3339 start timestamp. */
160 startTime: string | undefined;
161 /** RFC3339 end timestamp. */
162 endTime: string | undefined;
163 },
164 never,
165 Providers
166>;
167
168/**
169 * An App Lifecycle Manager rollout — one execution of a RolloutKind
170 * against a Release (or FlagRelease).
171 *
172 * `rolloutId`, `location`, `rolloutKind`, `release`, and `flagRelease`
173 * replace the rollout. Unit filter, strategy override, control action,
174 * labels, and annotations update in place. Requests to pause or cancel
175 * are only accepted while the rollout is non-terminal.
176 *
177 * ### Creating a Rollout
178 * **Example:** Roll a release
179 * ```typescript
180 * const rollout = yield* GCP.SaasServiceManagement.Rollout("Wave1", {
181 * rolloutKind: kind.name,
182 * release: release.name,
183 * labels: { env: "prod" },
184 * });
185 * ```
186 *
187 * ### Updating a Rollout
188 * **Example:** Pause
189 * ```typescript
190 * const rollout = yield* GCP.SaasServiceManagement.Rollout("Wave1", {
191 * rolloutId: rollout.rolloutId,
192 * rolloutKind: kind.name,
193 * release: release.name,
194 * control: { action: "ROLLOUT_ACTION_PAUSE" },
195 * });
196 * ```
197 *
198 * @resource
199 * @category SaasServiceManagement
200 */
201export const Rollout = Resource<Rollout>("GCP.SaasServiceManagement.Rollout");
202
203const toAttrs = (item: saasservicemgmt.Rollout, project: string) => {
204 const name = item.name ?? "";
205 const parsed = parseName(name, COLLECTION);
206 return {
207 name,
208 rolloutId: parsed.id,
209 project: parsed.project || project,
210 location: parsed.location,
211 rolloutKind: item.rolloutKind,
212 rolloutKindId: item.rolloutKind ? lastSegment(item.rolloutKind) : undefined,
213 release: item.release,
214 releaseId: item.release ? lastSegment(item.release) : undefined,
215 flagRelease: item.flagRelease,
216 unitFilter: item.unitFilter,
217 effectiveUnitFilter: item.effectiveUnitFilter,
218 rolloutOrchestrationStrategy: item.rolloutOrchestrationStrategy,
219 control: item.control
220 ? {
221 action: item.control.action,
222 runParams: item.control.runParams,
223 }
224 : undefined,
225 state: item.state,
226 stateMessage: item.stateMessage,
227 stats: item.stats,
228 rootRollout: item.rootRollout,
229 parentRollout: item.parentRollout,
230 labels: userLabels(item.labels),
231 annotations: userAnnotations(item.annotations),
232 uid: item.uid,
233 etag: item.etag,
234 createTime: item.createTime,
235 updateTime: item.updateTime,
236 startTime: item.startTime,
237 endTime: item.endTime,
238 };
239};
240
241const getByName = (name: string) =>
242 name.length === 0
243 ? Effect.succeed(undefined)
244 : saasservicemgmt
245 .getProjectsLocationsRollouts({ name })
246 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
247
248const listOwned = (project: string, location: string) =>
249 collectPages(
250 saasservicemgmt.listProjectsLocationsRollouts.pages({
251 parent: parentOf(project, "-"),
252 pageSize: 1000,
253 }),
254 (page) => page.rollouts,
255 ).pipe(
256 Effect.map((items) =>
257 items.filter((item) => hasAlchemyLabelKeys(item.labels)),
258 ),
259 Effect.flatMap((items) =>
260 items.length > 0
261 ? Effect.succeed(items)
262 : collectPages(
263 saasservicemgmt.listProjectsLocationsRollouts.pages({
264 parent: parentOf(project, location),
265 pageSize: 1000,
266 }),
267 (page) => page.rollouts,
268 ).pipe(
269 Effect.map((fallback) =>
270 fallback.filter((item) => hasAlchemyLabelKeys(item.labels)),
271 ),
272 ),
273 ),
274 );
275
276export const RolloutProvider = () =>
277 Provider.succeed(Rollout, {
278 stables: [
279 "name",
280 "rolloutId",
281 "project",
282 "location",
283 "rolloutKindId",
284 "releaseId",
285 "uid",
286 "createTime",
287 ],
288
289 diff: Effect.fn(function* ({ news, olds, output }) {
290 if (!isResolved(news)) return undefined;
291 const env = yield* GcpEnvironment.current;
292 return replaceOnIdentity({
293 previousId: olds?.rolloutId ?? output?.rolloutId,
294 nextId: news.rolloutId ?? olds?.rolloutId ?? output?.rolloutId,
295 previousLocation: normalizeLocation(
296 olds?.location ?? output?.location ?? env.region,
297 ),
298 nextLocation: normalizeLocation(
299 news.location ?? olds?.location ?? output?.location ?? env.region,
300 ),
301 extra:
302 (news.rolloutKind !== undefined &&
303 !sameRef(
304 olds?.rolloutKind ?? output?.rolloutKind,
305 news.rolloutKind,
306 )) ||
307 (news.release !== undefined &&
308 !sameRef(olds?.release ?? output?.release, news.release)) ||
309 (news.flagRelease !== undefined &&
310 (olds?.flagRelease ?? output?.flagRelease ?? "") !==
311 news.flagRelease),
312 });
313 }),
314
315 read: Effect.fn(function* ({ id, olds, output }) {
316 const env = yield* GcpEnvironment.current;
317 const rolloutId = yield* toPhysicalId(
318 id,
319 olds?.rolloutId,
320 output?.rolloutId,
321 "rlo",
322 );
323 const location = normalizeLocation(
324 olds?.location ?? output?.location ?? env.region,
325 );
326 const name =
327 output?.name ??
328 resourceName(env.project, location, COLLECTION, rolloutId);
329 const existing = yield* getByName(name);
330 if (existing === undefined) return undefined;
331 const attrs = toAttrs(existing, env.project);
332 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
333 ? attrs
334 : Unowned(attrs);
335 }),
336
337 list: () =>
338 Effect.gen(function* () {
339 const env = yield* GcpEnvironment.current;
340 const items = yield* listOwned(env.project, env.region);
341 return items.map((item) => toAttrs(item, env.project));
342 }),
343
344 reconcile: Effect.fn(function* ({ id, news, output }) {
345 const env = yield* GcpEnvironment.current;
346 const rolloutId = yield* toPhysicalId(
347 id,
348 news.rolloutId,
349 output?.rolloutId,
350 "rlo",
351 );
352 const location = normalizeLocation(
353 news.location ?? output?.location ?? env.region,
354 );
355 const name = resourceName(env.project, location, COLLECTION, rolloutId);
356 const desiredLabels = {
357 ...toLabels(news.labels),
358 ...(yield* createInternalLabels(id)),
359 };
360 const rolloutKind =
361 news.rolloutKind === undefined
362 ? undefined
363 : expandName(news.rolloutKind, env.project, location, "rolloutKinds");
364 const release =
365 news.release === undefined
366 ? undefined
367 : expandName(news.release, env.project, location, "releases");
368 const annotations = news.annotations;
369
370 let current = yield* getByName(output?.name ?? name);
371
372 if (current === undefined) {
373 const created = yield* saasservicemgmt
374 .createProjectsLocationsRollouts({
375 parent: parentOf(env.project, location),
376 rolloutId,
377 body: {
378 rolloutKind,
379 release,
380 flagRelease: news.flagRelease,
381 unitFilter: news.unitFilter,
382 rolloutOrchestrationStrategy: news.rolloutOrchestrationStrategy,
383 control: news.control,
384 labels: desiredLabels,
385 annotations,
386 },
387 })
388 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
389 current = created ?? undefined;
390 }
391
392 if (current === undefined) {
393 return yield* new ResourceNotResolved({ name });
394 }
395
396 const observedLabels = tagRecord(current.labels);
397 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
398 const labelsChanged = upsert.length > 0 || removed.length > 0;
399 const annotationsChanged =
400 annotations !== undefined &&
401 fingerprint(userAnnotations(current.annotations)) !==
402 fingerprint(annotations);
403 const filterChanged =
404 news.unitFilter !== undefined &&
405 (current.unitFilter ?? "") !== news.unitFilter;
406 const strategyChanged =
407 news.rolloutOrchestrationStrategy !== undefined &&
408 (current.rolloutOrchestrationStrategy ?? "") !==
409 news.rolloutOrchestrationStrategy;
410 const controlChanged =
411 news.control !== undefined &&
412 fingerprint(current.control) !== fingerprint(news.control);
413 const mask = fieldMask([
414 labelsChanged && "labels",
415 annotationsChanged && "annotations",
416 filterChanged && "unitFilter",
417 strategyChanged && "rolloutOrchestrationStrategy",
418 controlChanged && "control",
419 ]);
420
421 if (mask.length > 0) {
422 current = yield* saasservicemgmt.patchProjectsLocationsRollouts({
423 name: current.name ?? name,
424 updateMask: mask,
425 body: {
426 name: current.name ?? name,
427 etag: current.etag,
428 unitFilter: news.unitFilter,
429 rolloutOrchestrationStrategy: news.rolloutOrchestrationStrategy,
430 control: news.control,
431 labels: desiredLabels,
432 annotations,
433 },
434 });
435 }
436
437 return toAttrs(current, env.project);
438 }),
439
440 delete: Effect.fn(function* ({ output }) {
441 yield* saasservicemgmt
442 .deleteProjectsLocationsRollouts({ name: output.name })
443 .pipe(
444 Effect.retry({
445 while: (error) => error._tag === "Conflict",
446 times: 8,
447 schedule: Schedule.spaced("2 seconds"),
448 }),
449 Effect.catchTag("NotFound", () => Effect.void),
450 );
451 yield* waitUntilGone(getByName(output.name), output.name);
452 }),
453 });