1import * as Effect from "effect/Effect";
2import { createInternalTags, diffTags, hasTags, type Tags } from "../Tags.ts";
3
4/**
5 * GCP label keys may contain only lowercase letters, digits, underscores
6 * and dashes (no `:`, no `.`). Alchemy ownership tags
7 * `alchemy::stack` / `alchemy::stage` / `alchemy::id` map onto
8 * `alchemy-stack` / `alchemy-stage` / `alchemy-id`.
9 */
10export const ALCHEMY_LABEL_PREFIX = "alchemy-";
11
12export const alchemyLabelKeys = {
13 stack: "alchemy-stack",
14 stage: "alchemy-stage",
15 id: "alchemy-id",
16} as const;
17
18const TAG_PREFIX = "alchemy::";
19
20export const toLabelKey = (tagKey: string): string =>
21 tagKey.startsWith(TAG_PREFIX)
22 ? `${ALCHEMY_LABEL_PREFIX}${tagKey.slice(TAG_PREFIX.length)}`
23 : tagKey;
24
25export const toTagKey = (labelKey: string): string =>
26 labelKey.startsWith(ALCHEMY_LABEL_PREFIX)
27 ? `${TAG_PREFIX}${labelKey.slice(ALCHEMY_LABEL_PREFIX.length)}`
28 : labelKey;
29
30export const sanitizeLabelValue = (value: string): string => {
31 const cleaned = value
32 .replaceAll("/", "__")
33 .replace(/[^a-zA-Z0-9_-]/g, "-")
34 .replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, "")
35 .slice(0, 63)
36 .replace(/[^a-zA-Z0-9]+$/g, "")
37 .toLowerCase();
38 return cleaned.length > 0 ? cleaned : "x";
39};
40
41export const toLabels = (
42 tags: Record<string, string> | null | undefined,
43): Record<string, string> =>
44 Object.fromEntries(
45 Object.entries(tags ?? {}).map(([key, value]) => [
46 toLabelKey(key)
47 .toLowerCase()
48 .replace(/[^a-z0-9_-]/g, "-"),
49 sanitizeLabelValue(value),
50 ]),
51 );
52
53export const fromLabels = (
54 labels: Record<string, string> | null | undefined,
55): Record<string, string> =>
56 Object.fromEntries(
57 Object.entries(labels ?? {}).map(([key, value]) => [toTagKey(key), value]),
58 );
59
60export const createInternalLabels = Effect.fn(function* (id: string) {
61 return toLabels(yield* createInternalTags(id));
62});
63
64export const stripInternalLabels = (
65 labels: Record<string, string> | null | undefined,
66): Record<string, string> =>
67 Object.fromEntries(
68 Object.entries(labels ?? {}).filter(
69 ([key]) => !key.startsWith(ALCHEMY_LABEL_PREFIX),
70 ),
71 );
72
73export const hasAlchemyLabels = Effect.fn(function* (
74 id: string,
75 labels: Tags | undefined,
76) {
77 const expected = yield* createInternalLabels(id);
78 return hasTags(expected, labels);
79});
80
81/**
82 * Diff observed cloud labels against desired labels. Always pass
83 * **observed** labels as `oldLabels` — never `olds.labels` or
84 * `output.labels` — so adoption converges.
85 */
86export const diffLabels = diffTags;