1import * as Effect from "effect/Effect";
2import { createPhysicalName } from "../PhysicalName.ts";
3import { createInternalTags, diffTags, hasTags, type Tags } from "../Tags.ts";
4
5/**
6 * Fly App has no labels. Ownership is stamped onto Machine
7 * `config.metadata` (and encoded into Volume/Secret names) as
8 * `alchemy.stack` / `alchemy.stage` / `alchemy.id`.
9 *
10 * Names here are Fly-prefixed so `export *` from this module does not
11 * collide with Hetzner Labels or `alchemy/src/Ref.ts`.
12 */
13export const ALCHEMY_METADATA_PREFIX = "alchemy.";
14
15export const alchemyMetadataKeys = {
16 stack: "alchemy.stack",
17 stage: "alchemy.stage",
18 id: "alchemy.id",
19 type: "alchemy.type",
20 replica: "alchemy.replica",
21 instance: "alchemy.instance",
22 fqn: "alchemy.fqn",
23 baseName: "alchemy.base-name",
24 generation: "alchemy.generation",
25 workload: "alchemy.workload",
26 sequence: "alchemy.sequence",
27 count: "alchemy.count",
28 image: "alchemy.image",
29 containerImageSet: "alchemy.container-image-set",
30 phase: "alchemy.phase",
31 protocol: "alchemy.deployment-protocol",
32 roles: "alchemy.readiness-roles",
33 role: "alchemy.readiness-role",
34 predecessors: "alchemy.predecessors",
35 restored: "alchemy.idle-policy-restored",
36 checkedInstance: "alchemy.checked-instance",
37 secretsVersion: "alchemy.min-secrets-version",
38} as const;
39
40export type FlyAlchemyType = "Fly.Machine" | "Fly.Service";
41
42const TAG_PREFIX = "alchemy::";
43
44export const toMetadataKey = (tagKey: string): string =>
45 tagKey.startsWith(TAG_PREFIX)
46 ? `${ALCHEMY_METADATA_PREFIX}${tagKey.slice(TAG_PREFIX.length)}`
47 : tagKey;
48
49export const toTagKeyFromMetadata = (metadataKey: string): string =>
50 metadataKey.startsWith(ALCHEMY_METADATA_PREFIX)
51 ? `${TAG_PREFIX}${metadataKey.slice(ALCHEMY_METADATA_PREFIX.length)}`
52 : metadataKey;
53
54export const toMachineMetadata = (
55 tags: Record<string, string> | null | undefined,
56): Record<string, string> =>
57 Object.fromEntries(
58 Object.entries(tags ?? {}).map(([key, value]) => [
59 toMetadataKey(key),
60 value,
61 ]),
62 );
63
64export const fromMachineMetadata = (
65 metadata: Record<string, string> | null | undefined,
66): Record<string, string> =>
67 Object.fromEntries(
68 Object.entries(metadata ?? {}).map(([key, value]) => [
69 toTagKeyFromMetadata(key),
70 value,
71 ]),
72 );
73
74export const createInternalMetadata = Effect.fn(function* (id: string) {
75 return toMachineMetadata(yield* createInternalTags(id));
76});
77
78/**
79 * Stamp alchemy ownership plus `alchemy.type` onto Machine config.metadata.
80 */
81export const createMachineMetadata = Effect.fn(function* (
82 id: string,
83 type: FlyAlchemyType,
84) {
85 return {
86 ...(yield* createInternalMetadata(id)),
87 [alchemyMetadataKeys.type]: type,
88 };
89});
90
91export const stripInternalMetadata = (
92 metadata: Record<string, string> | null | undefined,
93): Record<string, string> =>
94 Object.fromEntries(
95 Object.entries(metadata ?? {}).filter(
96 ([key]) => !key.startsWith(ALCHEMY_METADATA_PREFIX),
97 ),
98 );
99
100export const hasAlchemyMetadata = Effect.fn(function* (
101 id: string,
102 metadata: Tags | undefined,
103) {
104 const expected = yield* createInternalMetadata(id);
105 return hasTags(expected, metadata);
106});
107
108/** True when observed Machine metadata was stamped by Alchemy. */
109export const isAlchemyOwnedMetadata = (
110 metadata: Record<string, string | undefined> | null | undefined,
111): boolean => {
112 const stack = metadata?.[alchemyMetadataKeys.stack];
113 return stack !== undefined && stack.length > 0;
114};
115
116/**
117 * Diff observed Machine metadata against desired. Always pass **observed**
118 * cloud metadata as `oldMetadata` — never `olds.metadata` or
119 * `output.metadata` — so adoption converges.
120 */
121export const diffMachineMetadata = diffTags;
122
123/**
124 * Fly App names: `createPhysicalName({ lowercase: true, maxLength: 30 })`,
125 * then force a leading letter (`f` prefix if needed). Globally unique.
126 */
127export const createFlyAppName = Effect.fn(function* (id: string) {
128 const raw = yield* createPhysicalName({
129 id,
130 lowercase: true,
131 maxLength: 30,
132 });
133 return /^[a-z]/.test(raw) ? raw : `f${raw}`.slice(0, 30);
134});
135
136/**
137 * Volume / Secret / SecretKey physical names use the same shape as App
138 * names so `list()` can recognize alchemy-owned rows without labels.
139 *
140 * Fly Volume names reject hyphens (`[a-z0-9_]`, max 30). Use
141 * {@link createFlyVolumeName} for Volumes.
142 */
143export const createFlyResourceName = createFlyAppName;
144
145/**
146 * Fly Volume names: same physical-name shape as App, but underscore
147 * delimited. Fly rejects hyphens (`[a-z0-9_]`, max 30).
148 */
149export const createFlyVolumeName = Effect.fn(function* (id: string) {
150 const raw = yield* createPhysicalName({
151 id,
152 lowercase: true,
153 maxLength: 30,
154 delimiter: "_",
155 });
156 // createPhysicalName keeps hyphens in the stack/stage prefix; Fly
157 // Volume names only allow [a-z0-9_].
158 return sanitizeFlyVolumeName(raw);
159});
160
161/**
162 * Sanitize a user-supplied Fly App / Volume / Secret name: lowercase,
163 * DNS-compatible (`[a-z0-9-]`), force a leading letter, max 30 chars.
164 */
165export const sanitizeFlyAppName = (name: string): string => {
166 const lowered = name
167 .toLowerCase()
168 .replace(/[^a-z0-9-]+/g, "-")
169 .replace(/-+/g, "-")
170 .replace(/^-+|-+$/g, "");
171 const clipped =
172 lowered.length > 30 ? lowered.slice(0, 30).replace(/-+$/g, "") : lowered;
173 const raw = clipped.length === 0 ? "f" : clipped;
174 return /^[a-z]/.test(raw) ? raw : `f${raw}`.slice(0, 30);
175};
176
177/**
178 * Sanitize a user-supplied Fly Volume name: lowercase alphanumeric and
179 * underscores only, force a leading letter, max 30 chars.
180 */
181export const sanitizeFlyVolumeName = (name: string): string => {
182 const lowered = name
183 .toLowerCase()
184 .replace(/[^a-z0-9_]+/g, "_")
185 .replace(/_+/g, "_")
186 .replace(/^_+|_+$/g, "");
187 const clipped =
188 lowered.length > 30 ? lowered.slice(0, 30).replace(/_+$/g, "") : lowered;
189 const raw = clipped.length === 0 ? "f" : clipped;
190 return /^[a-z]/.test(raw) ? raw : `f${raw}`.slice(0, 30);
191};
192
193/**
194 * True when `name` matches the `createPhysicalName` + leading-letter shape
195 * used for alchemy-owned Fly Apps / Volumes / Secrets / SecretKeys.
196 *
197 * Untruncated names end with a hyphen (App) or underscore (Volume) plus
198 * an 8–16 char RFC4648 base32 instance suffix. Truncated 30-char names
199 * keep that suffix (the human prefix is what gets cut).
200 */
201export const matchesAlchemyPhysicalName = (
202 name: string | undefined,
203): boolean => {
204 if (name === undefined || name.length === 0 || name.length > 30) {
205 return false;
206 }
207 if (!/^[a-z][a-z0-9_-]*$/.test(name)) return false;
208 const parts = name.split(/[-_]/);
209 const last = parts.at(-1) ?? "";
210 if (
211 parts.length >= 2 &&
212 last.length >= 8 &&
213 last.length <= 16 &&
214 /^[a-z2-7]+$/.test(last)
215 ) {
216 return true;
217 }
218 const compact = name.replaceAll("-", "").replaceAll("_", "");
219 return name.length === 30 && /[a-z2-7]{16}$/.test(compact);
220};