1import { ignoredCodes } from "./internal.ts";
2import * as compute from "@distilled.cloud/gcp/compute_v1";
3import { waitGlobalOperation } from "./operations.ts";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 alchemyLabelKeys,
16 createInternalLabels,
17 hasAlchemyLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const MAX_NAME_LENGTH = 63;
22
23export type LicenseDuration = compute.Duration;
24export type LicenseResourceRequirements = compute.LicenseResourceRequirements;
25
26export type LicenseProps = {
27 /**
28 * License name (RFC1035, 1-63 characters). If omitted, a unique name is
29 * generated from the stack, stage, and logical id. Immutable — changing
30 * it replaces the license.
31 */
32 licenseName?: string;
33 /**
34 * Optional description. Compute licenses have no labels field, so
35 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
36 * is stored in a `[alchemy …]` prefix for `list` / nuke. Immutable —
37 * `licenses.update` rejects it, so changing it replaces the license.
38 */
39 description?: string;
40 /**
41 * When `true`, this is an OS license. Only one OS license can be
42 * attached to a disk or image. Immutable — changing it replaces the
43 * license.
44 * @default false
45 */
46 osLicense?: boolean;
47 /**
48 * When `false`, licenses are not copied from the source when creating
49 * an image from a disk (or disk from snapshot, snapshot from disk).
50 * Immutable — changing it replaces the license.
51 * @default true
52 */
53 transferable?: boolean;
54 /**
55 * When `true`, this license can be appended to an existing disk.
56 */
57 appendableToDisk?: boolean;
58 /**
59 * When `true`, this license can be removed from a disk with no
60 * replacement license.
61 */
62 removableFromDisk?: boolean;
63 /**
64 * When `true`, this license can only be used on multi-tenant nodes.
65 * Immutable — changing it replaces the license.
66 */
67 multiTenantOnly?: boolean;
68 /**
69 * When `true`, this license can only be used on sole-tenant nodes.
70 * Immutable — changing it replaces the license.
71 */
72 soleTenantOnly?: boolean;
73 /**
74 * Minimum time this license must stay attached once applied.
75 */
76 minimumRetention?: LicenseDuration;
77 /**
78 * License codes that are incompatible with this license. Immutable —
79 * changing it replaces the license.
80 */
81 incompatibleLicenses?: string[];
82 /**
83 * License codes that can replace this license.
84 */
85 allowedReplacementLicenses?: string[];
86 /**
87 * License codes that must be co-attached with this license.
88 */
89 requiredCoattachedLicenses?: string[];
90 /**
91 * Deprecated resource-requirement hints (cores, ram).
92 */
93 resourceRequirements?: LicenseResourceRequirements;
94};
95
96export type License = Resource<
97 "GCP.Compute.License",
98 LicenseProps,
99 {
100 /** License name. */
101 licenseName: string;
102 /** Project id. */
103 project: string;
104 /** User description with the Alchemy ownership prefix stripped. */
105 description: string | undefined;
106 /** Unique code used to attach this license to images and disks. */
107 licenseCode: string | undefined;
108 /** Whether this is an OS license. */
109 osLicense: boolean;
110 /** Whether the license is copied from source resources. */
111 transferable: boolean;
112 /** Whether the license can be appended to an existing disk. */
113 appendableToDisk: boolean;
114 /** Whether the license can be removed from a disk. */
115 removableFromDisk: boolean;
116 /** Whether the license is multi-tenant only. */
117 multiTenantOnly: boolean;
118 /** Whether the license is sole-tenant only. */
119 soleTenantOnly: boolean;
120 /** Minimum retention period. */
121 minimumRetention: LicenseDuration | undefined;
122 /** Incompatible license codes. */
123 incompatibleLicenses: ReadonlyArray<string>;
124 /** Replacement license codes. */
125 allowedReplacementLicenses: ReadonlyArray<string>;
126 /** Required co-attached license codes. */
127 requiredCoattachedLicenses: ReadonlyArray<string>;
128 /** Deprecated resource requirements. */
129 resourceRequirements: LicenseResourceRequirements | undefined;
130 /** Server-assigned numeric id. */
131 licenseId: string | undefined;
132 /** Resource self-link. */
133 selfLink: string | undefined;
134 /** Self-link including the numeric id. */
135 selfLinkWithId: string | undefined;
136 /** RFC3339 creation timestamp. */
137 creationTimestamp: string | undefined;
138 /** RFC3339 last-update timestamp. */
139 updateTimestamp: string | undefined;
140 /** Resource kind. */
141 kind: string | undefined;
142 },
143 never,
144 Providers
145>;
146
147/**
148 * A global Compute Engine License.
149 *
150 * Licenses are intended for third-party partners who publish Cloud
151 * Marketplace images. Name and `osLicense` are immutable. Description and
152 * the attachability flags update in place via `licenses.update`. Compute
153 * License has no labels field — Alchemy stamps ownership into the
154 * description so nuke can find leaked licenses.
155 *
156 * ### Creating a License
157 * **Example:** Generated name
158 * ```typescript
159 * const license = yield* GCP.Compute.License("ImageLicense", {
160 * description: "marketplace os",
161 * transferable: true,
162 * });
163 * ```
164 *
165 * **Example:** Named OS license
166 * ```typescript
167 * const license = yield* GCP.Compute.License("ImageLicense", {
168 * licenseName: "app-os",
169 * osLicense: true,
170 * transferable: false,
171 * removableFromDisk: false,
172 * });
173 * ```
174 *
175 * @resource
176 * @category Compute
177 */
178export const License = Resource<License>("GCP.Compute.License");
179
180export class LicenseNotResolved extends Data.TaggedError(
181 "GCP.Compute.LicenseNotResolved",
182)<{
183 licenseName: string;
184}> {}
185
186export class LicenseStillExists extends Data.TaggedError(
187 "GCP.Compute.LicenseStillExists",
188)<{
189 licenseName: string;
190}> {}
191
192const lastSegment = (value: string | undefined): string => {
193 if (value === undefined || value.length === 0) return "";
194 const trimmed = value.replace(/\/+$/, "");
195 const parts = trimmed.split("/");
196 return parts[parts.length - 1] || trimmed;
197};
198
199const rfc1035 = (name: string): string => {
200 let next = name
201 .toLowerCase()
202 .replace(/[^a-z0-9-]/g, "-")
203 .replace(/-+/g, "-")
204 .replace(/^-+/, "")
205 .replace(/-+$/, "");
206 if (!/^[a-z]/.test(next)) next = `l${next}`;
207 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
208 return next.length > 0 ? next : "license";
209};
210
211const toName = (id: string, name: string | undefined, existing?: string) =>
212 Effect.gen(function* () {
213 if (name !== undefined) return name;
214 if (existing !== undefined) return existing;
215 return rfc1035(
216 yield* createPhysicalName({
217 id,
218 maxLength: MAX_NAME_LENGTH,
219 lowercase: true,
220 }),
221 );
222 });
223
224const encodeDescription = (
225 labels: Record<string, string>,
226 description: string | undefined,
227): string => {
228 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
229 return description ? `${marker}\n${description}` : marker;
230};
231
232const parseDescription = (
233 description: string | undefined,
234): {
235 labels: Record<string, string>;
236 description: string | undefined;
237} => {
238 if (!description?.startsWith("[alchemy ")) {
239 return { labels: {}, description };
240 }
241 const end = description.indexOf("]");
242 if (end < 0) return { labels: {}, description };
243 const labels: Record<string, string> = {};
244 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
245 const eq = part.indexOf("=");
246 if (eq > 0) labels[part.slice(0, eq)] = part.slice(eq + 1);
247 }
248 const rest = description.slice(end + 1).replace(/^\n/, "");
249 return { labels, description: rest.length > 0 ? rest : undefined };
250};
251
252const hasOwnershipMarker = (description: string | undefined) =>
253 Object.keys(parseDescription(description).labels).some((key) =>
254 key.startsWith("alchemy-"),
255 );
256
257const sorted = (values: ReadonlyArray<string> | undefined) =>
258 [...(values ?? [])].map((value) => lastSegment(value)).sort();
259
260const sameJson = (left: unknown, right: unknown) =>
261 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
262
263const immutableChanged = (olds: LicenseProps, news: LicenseProps) =>
264 (olds.description ?? "") !== (news.description ?? "") ||
265 (olds.transferable ?? true) !== (news.transferable ?? true) ||
266 (olds.multiTenantOnly ?? false) !== (news.multiTenantOnly ?? false) ||
267 (olds.soleTenantOnly ?? false) !== (news.soleTenantOnly ?? false) ||
268 sorted(olds.incompatibleLicenses).join(",") !==
269 sorted(news.incompatibleLicenses).join(",") ||
270 sorted(olds.requiredCoattachedLicenses).join(",") !==
271 sorted(news.requiredCoattachedLicenses).join(",");
272
273const toAttrs = (
274 license: compute.License,
275 project: string,
276): License["Attributes"] => {
277 const parsed = parseDescription(license.description);
278 return {
279 licenseName: license.name ?? "",
280 project,
281 description: parsed.description,
282 licenseCode: license.licenseCode,
283 osLicense: license.osLicense === true,
284 transferable: license.transferable !== false,
285 appendableToDisk: license.appendableToDisk === true,
286 removableFromDisk: license.removableFromDisk === true,
287 multiTenantOnly: license.multiTenantOnly === true,
288 soleTenantOnly: license.soleTenantOnly === true,
289 minimumRetention: license.minimumRetention,
290 incompatibleLicenses: license.incompatibleLicenses ?? [],
291 allowedReplacementLicenses: license.allowedReplacementLicenses ?? [],
292 requiredCoattachedLicenses: license.requiredCoattachedLicenses ?? [],
293 resourceRequirements: license.resourceRequirements,
294 licenseId: license.id,
295 selfLink: license.selfLink,
296 selfLinkWithId: license.selfLinkWithId,
297 creationTimestamp: license.creationTimestamp,
298 updateTimestamp: license.updateTimestamp,
299 kind: license.kind,
300 };
301};
302
303const getByName = (project: string, license: string) =>
304 compute
305 .getLicenses({ project, license })
306 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
307
308const awaitResource = (project: string, licenseName: string) =>
309 getByName(project, licenseName).pipe(
310 Effect.flatMap((license) =>
311 license !== undefined
312 ? Effect.succeed(license)
313 : Effect.fail(new LicenseNotResolved({ licenseName })),
314 ),
315 Effect.retry({
316 while: (error) => error._tag === "GCP.Compute.LicenseNotResolved",
317 times: 8,
318 schedule: Schedule.spaced("1 second"),
319 }),
320 );
321
322const waitUntilGone = (project: string, licenseName: string) =>
323 getByName(project, licenseName).pipe(
324 Effect.flatMap((license) =>
325 license === undefined
326 ? Effect.void
327 : Effect.fail(new LicenseStillExists({ licenseName })),
328 ),
329 Effect.retry({
330 while: (error) => error._tag === "GCP.Compute.LicenseStillExists",
331 times: 10,
332 schedule: Schedule.spaced("1 second"),
333 }),
334 Effect.catchTag("GCP.Compute.LicenseStillExists", () => Effect.void),
335 );
336
337const runOp = <E extends { readonly _tag: string }, R>(
338 project: string,
339 licenseName: string,
340 start: Effect.Effect<compute.Operation, E, R>,
341 options?: { ignoreAlreadyExists?: boolean; ignoreNotFound?: boolean },
342) =>
343 start.pipe(
344 Effect.flatMap((operation) =>
345 waitGlobalOperation(project, operation, {
346 ignore: ignoredCodes(options),
347 }),
348 ),
349 Effect.retry({
350 while: (error) => error._tag === "Conflict",
351 times: 5,
352 schedule: Schedule.spaced("1 second"),
353 }),
354 );
355
356const insertBody = (
357 licenseName: string,
358 news: LicenseProps,
359 description: string,
360): compute.License => ({
361 name: licenseName,
362 description,
363 osLicense: news.osLicense === true ? true : undefined,
364 transferable: news.transferable,
365 appendableToDisk: news.appendableToDisk,
366 removableFromDisk: news.removableFromDisk,
367 multiTenantOnly: news.multiTenantOnly,
368 soleTenantOnly: news.soleTenantOnly,
369 minimumRetention: news.minimumRetention,
370 incompatibleLicenses: news.incompatibleLicenses,
371 allowedReplacementLicenses: news.allowedReplacementLicenses,
372 requiredCoattachedLicenses: news.requiredCoattachedLicenses,
373 resourceRequirements: news.resourceRequirements,
374});
375
376export const LicenseProvider = () =>
377 Provider.succeed(License, {
378 stables: [
379 "licenseName",
380 "project",
381 "licenseId",
382 "licenseCode",
383 "osLicense",
384 "selfLink",
385 "selfLinkWithId",
386 "creationTimestamp",
387 ],
388
389 diff: Effect.fn(function* ({ news, olds, output }) {
390 if (!isResolved(news)) return undefined;
391 const previousName = olds?.licenseName ?? output?.licenseName;
392 const nextName = news.licenseName ?? previousName;
393 const nameChanged =
394 previousName !== undefined &&
395 nextName !== undefined &&
396 previousName !== nextName;
397 const previousOs = olds?.osLicense ?? output?.osLicense ?? false;
398 const nextOs = news.osLicense ?? previousOs;
399 if (nameChanged) {
400 return { action: "replace" as const, deleteFirst: false };
401 }
402 if (previousOs !== nextOs) {
403 return { action: "replace" as const, deleteFirst: true };
404 }
405 // `licenses.update` only accepts appendableToDisk, removableFromDisk,
406 // allowedReplacementLicenses, and minimumRetention; every other field
407 // is fixed at insert.
408 if (olds !== undefined && immutableChanged(olds, news)) {
409 return {
410 action: "replace" as const,
411 deleteFirst: news.licenseName !== undefined,
412 };
413 }
414 return undefined;
415 }),
416
417 read: Effect.fn(function* ({ id, olds, output }) {
418 const env = yield* GcpEnvironment.current;
419 const licenseName = yield* toName(
420 id,
421 olds?.licenseName,
422 output?.licenseName,
423 );
424 const existing = yield* getByName(env.project, licenseName);
425 if (existing === undefined) return undefined;
426 const attrs = toAttrs(existing, env.project);
427 const { labels } = parseDescription(existing.description);
428 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
429 }),
430
431 list: () =>
432 Effect.gen(function* () {
433 const env = yield* GcpEnvironment.current;
434 return yield* compute.listLicenses
435 .items({
436 project: env.project,
437 maxResults: 500,
438 })
439 .pipe(
440 Stream.filter((license) => hasOwnershipMarker(license.description)),
441 Stream.map((license) => toAttrs(license, env.project)),
442 Stream.runCollect,
443 Effect.map((items) => Array.from(items)),
444 Effect.catchTag("NotFound", () =>
445 Effect.succeed([] as License["Attributes"][]),
446 ),
447 );
448 }),
449
450 reconcile: Effect.fn(function* ({ id, news, output }) {
451 const env = yield* GcpEnvironment.current;
452 const licenseName = yield* toName(
453 id,
454 news.licenseName,
455 output?.licenseName,
456 );
457 const ownership = yield* createInternalLabels(id);
458 const desiredDescription = encodeDescription(ownership, news.description);
459
460 let current = yield* getByName(env.project, licenseName);
461
462 if (current === undefined) {
463 yield* compute
464 .insertLicenses({
465 project: env.project,
466 body: insertBody(licenseName, news, desiredDescription),
467 })
468 .pipe(
469 Effect.flatMap((operation) =>
470 waitGlobalOperation(env.project, operation, {
471 ignore: ["RESOURCE_ALREADY_EXISTS"],
472 }),
473 ),
474 Effect.catchTag("Conflict", () => Effect.void),
475 );
476 current = yield* awaitResource(env.project, licenseName);
477 }
478
479 const appendable =
480 news.appendableToDisk ?? current.appendableToDisk === true;
481 const removable =
482 news.removableFromDisk ?? current.removableFromDisk === true;
483 const updateMask = [
484 (current.appendableToDisk === true) !== appendable &&
485 "appendableToDisk",
486 (current.removableFromDisk === true) !== removable &&
487 "removableFromDisk",
488 news.allowedReplacementLicenses !== undefined &&
489 sorted(current.allowedReplacementLicenses).join(",") !==
490 sorted(news.allowedReplacementLicenses).join(",") &&
491 "allowedReplacementLicenses",
492 news.minimumRetention !== undefined &&
493 !sameJson(current.minimumRetention, news.minimumRetention) &&
494 "minimumRetention",
495 ].filter((field): field is string => typeof field === "string");
496
497 if (updateMask.length > 0) {
498 yield* runOp(
499 env.project,
500 licenseName,
501 compute.updateLicenses({
502 project: env.project,
503 license: licenseName,
504 updateMask: updateMask.join(","),
505 body: {
506 appendableToDisk: appendable,
507 removableFromDisk: removable,
508 allowedReplacementLicenses:
509 news.allowedReplacementLicenses ??
510 current.allowedReplacementLicenses,
511 minimumRetention:
512 news.minimumRetention ?? current.minimumRetention,
513 },
514 }),
515 );
516 current = (yield* getByName(env.project, licenseName)) ?? current;
517 }
518
519 return toAttrs(current, env.project);
520 }),
521
522 delete: Effect.fn(function* ({ output }) {
523 if (!output.licenseName) return;
524 const env = yield* GcpEnvironment.current;
525 const project = output.project || env.project;
526 yield* compute
527 .deleteLicenses({
528 project,
529 license: output.licenseName,
530 })
531 .pipe(
532 Effect.flatMap((operation) =>
533 waitGlobalOperation(project, operation, {
534 ignore: ["RESOURCE_NOT_FOUND"],
535 }),
536 ),
537 Effect.catchTag("NotFound", () => Effect.void),
538 Effect.retry({
539 while: (error) => error._tag === "Conflict",
540 times: 8,
541 schedule: Schedule.spaced("2 seconds"),
542 }),
543 );
544 yield* waitUntilGone(project, output.licenseName);
545 }),
546 });