1import * as Effect from "effect/Effect";
2import { createInternalTags, diffTags, hasTags, type Tags } from "../Tags.ts";
3
4/**
5 * Hetzner label keys cannot contain `:`. Alchemy ownership tags use
6 * `alchemy::stack` / `alchemy::stage` / `alchemy::id` — map those onto
7 * `alchemy.stack` / `alchemy.stage` / `alchemy.id` so they survive the
8 * Cloud API, and invert the mapping when reading observed labels back.
9 *
10 * Label values must match `^[A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$`
11 * (or be empty). FQNs that contain `/` are rewritten to `__`.
12 */
13export const ALCHEMY_LABEL_PREFIX = "alchemy.";
14
15export const alchemyLabelKeys = {
16 stack: "alchemy.stack",
17 stage: "alchemy.stage",
18 id: "alchemy.id",
19} as const;
20
21/**
22 * Hetzner `label_selector` matching any resource Alchemy stamped with
23 * `alchemy.stack`. Used by `list` so nuke only enumerates our rows.
24 */
25export const alchemyStackSelector = alchemyLabelKeys.stack;
26
27const TAG_PREFIX = "alchemy::";
28
29export const toLabelKey = (tagKey: string): string =>
30 tagKey.startsWith(TAG_PREFIX)
31 ? `${ALCHEMY_LABEL_PREFIX}${tagKey.slice(TAG_PREFIX.length)}`
32 : tagKey;
33
34export const toTagKey = (labelKey: string): string =>
35 labelKey.startsWith(ALCHEMY_LABEL_PREFIX)
36 ? `${TAG_PREFIX}${labelKey.slice(ALCHEMY_LABEL_PREFIX.length)}`
37 : labelKey;
38
39export const sanitizeLabelValue = (value: string): string => {
40 const cleaned = value
41 .replaceAll("/", "__")
42 .replace(/[^A-Za-z0-9._-]/g, "-")
43 .replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, "")
44 .slice(0, 63)
45 .replace(/[^A-Za-z0-9]+$/g, "");
46 return cleaned.length > 0 ? cleaned : "x";
47};
48
49export const toLabels = (
50 tags: Record<string, string> | null | undefined,
51): Record<string, string> =>
52 Object.fromEntries(
53 Object.entries(tags ?? {}).map(([key, value]) => [
54 toLabelKey(key),
55 sanitizeLabelValue(value),
56 ]),
57 );
58
59export const fromLabels = (
60 labels: Record<string, string> | null | undefined,
61): Record<string, string> =>
62 Object.fromEntries(
63 Object.entries(labels ?? {}).map(([key, value]) => [toTagKey(key), value]),
64 );
65
66export const createInternalLabels = Effect.fn(function* (id: string) {
67 return toLabels(yield* createInternalTags(id));
68});
69
70export const stripInternalLabels = (
71 labels: Record<string, string> | null | undefined,
72): Record<string, string> =>
73 Object.fromEntries(
74 Object.entries(labels ?? {}).filter(
75 ([key]) => !key.startsWith(ALCHEMY_LABEL_PREFIX),
76 ),
77 );
78
79export const hasAlchemyLabels = Effect.fn(function* (
80 id: string,
81 labels: Tags | undefined,
82) {
83 const expected = yield* createInternalLabels(id);
84 return hasTags(expected, labels);
85});
86
87/**
88 * Diff observed cloud labels against desired labels. Always pass
89 * **observed** labels as `oldLabels` — never `olds.labels` or
90 * `output.labels` — so adoption converges.
91 */
92export const diffLabels = diffTags;
93
94/**
95 * Hetzner label-selector string for a label map (`key=value,key=value`).
96 */
97export const labelSelector = (labels: Record<string, string>): string =>
98 Object.entries(labels)
99 .map(([key, value]) => `${key}=${value}`)
100 .join(",");