1import * as Hetzner from "@distilled.cloud/hetzner";
2import type { GetPlacementGroupResponsePlacementGroup } from "@distilled.cloud/hetzner/placement_groups";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../AdoptPolicy.ts";
8import { isResolved } from "../Diff.ts";
9import { createPhysicalName } from "../PhysicalName.ts";
10import * as Provider from "../Provider.ts";
11import { Resource } from "../Resource.ts";
12import { tagRecord } from "../Tags.ts";
13import { waitForAction } from "./actions.ts";
14import {
15 alchemyLabelKeys,
16 alchemyStackSelector,
17 createInternalLabels,
18 diffLabels,
19 hasAlchemyLabels,
20 labelSelector,
21 stripInternalLabels,
22 toLabels,
23} from "./Labels.ts";
24import type { Providers } from "./Providers.ts";
25
26/**
27 * Placement Group type. Hetzner currently offers only `spread` — Servers
28 * in the group are placed on distinct physical hosts.
29 */
30export type PlacementGroupType = "spread";
31
32export interface PlacementGroupProps {
33 /**
34 * Name of the Placement Group. Must be unique per Hetzner project. If
35 * omitted, a unique name is generated from `${stack}-${id}-${stage}`.
36 */
37 name?: string;
38 /**
39 * Placement strategy. `spread` places each Server on a different
40 * physical host. Cannot be changed after creation — changing it
41 * replaces the group.
42 *
43 * @default "spread"
44 */
45 type?: PlacementGroupType;
46 /**
47 * User-defined labels (`key/value` pairs). Alchemy ownership labels
48 * (`alchemy.stack`, `alchemy.stage`, `alchemy.id`) are merged in
49 * automatically.
50 */
51 labels?: Record<string, string>;
52}
53
54export type PlacementGroup = Resource<
55 "Hetzner.PlacementGroup",
56 PlacementGroupProps,
57 {
58 /**
59 * Numeric Hetzner Placement Group ID.
60 */
61 id: number;
62 /**
63 * Name of the Placement Group. Unique per project.
64 */
65 name: string;
66 /**
67 * Placement strategy. Currently always `spread`.
68 */
69 type: PlacementGroupType;
70 /**
71 * User-defined labels (Alchemy ownership labels stripped).
72 */
73 labels: Record<string, string>;
74 /**
75 * RFC3339 timestamp of when the Placement Group was created.
76 */
77 created: string;
78 /**
79 * IDs of Servers currently assigned to this group. Server
80 * attachment is configured on the Server resource.
81 */
82 servers: number[];
83 },
84 never,
85 Providers
86>;
87
88/**
89 * A Hetzner Cloud Placement Group. Spread groups keep member Servers on
90 * distinct physical hosts so a single hardware failure cannot take them
91 * all down. Type is `spread` (the only type Hetzner currently offers)
92 * and is immutable — changing it replaces the group. Name and labels
93 * update in place.
94 *
95 * Servers are attached from the Server resource (not here).
96 *
97 * @see https://docs.hetzner.cloud/reference/cloud#placement-groups
98 *
99 * ### Creating a Placement Group
100 * **Example:** Basic spread group
101 * ```typescript
102 * const group = yield* Hetzner.PlacementGroup("web");
103 * ```
104 *
105 * **Example:** Named group with labels
106 * ```typescript
107 * const group = yield* Hetzner.PlacementGroup("web", {
108 * name: "web-spread",
109 * type: "spread",
110 * labels: { role: "web" },
111 * });
112 * ```
113 *
114 * @resource
115 * @product Server
116 */
117export const PlacementGroup = Resource<PlacementGroup>(
118 "Hetzner.PlacementGroup",
119);
120
121export class PlacementGroupNotResolved extends Data.TaggedError(
122 "Hetzner.PlacementGroupNotResolved",
123)<{
124 name: string;
125}> {}
126
127const DEFAULT_TYPE: PlacementGroupType = "spread";
128
129const userLabels = (
130 labels: Record<string, string | undefined> | null | undefined,
131): Record<string, string> => stripInternalLabels(tagRecord(labels));
132
133const toName = (id: string, name: string | undefined, existing?: string) =>
134 Effect.gen(function* () {
135 return (
136 name ?? existing ?? (yield* createPhysicalName({ id, maxLength: 64 }))
137 );
138 });
139
140const toAttrs = (group: GetPlacementGroupResponsePlacementGroup) => ({
141 id: group.id,
142 name: group.name,
143 type: group.type as PlacementGroupType,
144 labels: userLabels(group.labels),
145 created: group.created,
146 servers: group.servers,
147});
148
149const getById = (id: number) =>
150 Hetzner.placementGroups.getPlacementGroup({ id }).pipe(
151 Effect.map(({ placement_group }) => placement_group),
152 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
153 );
154
155const findByName = (name: string) =>
156 Hetzner.placementGroups
157 .listPlacementGroups({ name, per_page: 50 })
158 .pipe(
159 Effect.map(({ placement_groups }) =>
160 placement_groups.find((group) => group.name === name),
161 ),
162 );
163
164const findByAlchemyLabels = (id: string) =>
165 Effect.gen(function* () {
166 const internal = yield* createInternalLabels(id);
167 const { placement_groups } =
168 yield* Hetzner.placementGroups.listPlacementGroups({
169 label_selector: labelSelector(internal),
170 per_page: 50,
171 });
172 return placement_groups.find((group) => {
173 const labels = tagRecord(group.labels);
174 return Object.entries(internal).every(
175 ([key, value]) => labels[key] === value,
176 );
177 });
178 });
179
180const observe = Effect.fn(function* (input: {
181 id?: number;
182 name?: string;
183 logicalId: string;
184}) {
185 if (input.id !== undefined) {
186 const byId = yield* getById(input.id);
187 if (byId !== undefined) return byId;
188 }
189 if (input.name !== undefined) {
190 const byName = yield* findByName(input.name);
191 if (byName !== undefined) return byName;
192 }
193 return yield* findByAlchemyLabels(input.logicalId);
194});
195
196/**
197 * Poll `getPlacementGroup` until it returns the typed `NotFound` tag.
198 * Bounded to 10 attempts at 1s spacing.
199 */
200export const waitUntilPlacementGroupGone = (id: number) =>
201 Hetzner.placementGroups.getPlacementGroup({ id }).pipe(
202 Effect.as("found" as const),
203 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
204 Effect.repeat({
205 schedule: Schedule.spaced("1 second"),
206 until: (status) => status === "gone",
207 times: 10,
208 }),
209 );
210
211export const PlacementGroupProvider = () =>
212 Provider.succeed(PlacementGroup, {
213 stables: ["id", "type", "created"],
214
215 diff: Effect.fn(function* ({ news, output }) {
216 if (!isResolved(news)) return undefined;
217 const nextType = news.type ?? DEFAULT_TYPE;
218 if (output !== undefined && nextType !== output.type) {
219 return { action: "replace" } as const;
220 }
221 return undefined;
222 }),
223
224 read: Effect.fn(function* ({ id, olds, output }) {
225 const name = yield* toName(id, olds?.name, output?.name);
226 const existing = yield* observe({
227 id: output?.id,
228 name,
229 logicalId: id,
230 });
231 if (existing === undefined) return undefined;
232 const attrs = toAttrs(existing);
233 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
234 ? attrs
235 : Unowned(attrs);
236 }),
237
238 list: () =>
239 Hetzner.placementGroups.listPlacementGroups
240 .items({ label_selector: alchemyStackSelector, per_page: 50 })
241 .pipe(
242 Stream.runCollect,
243 Effect.map((chunk) => Array.from(chunk, toAttrs)),
244 ),
245
246 reconcile: Effect.fn(function* ({ id, news, output }) {
247 const type = news.type ?? DEFAULT_TYPE;
248 const name = yield* toName(id, news.name, output?.name);
249 const desired = {
250 ...toLabels(news.labels),
251 ...(yield* createInternalLabels(id)),
252 };
253
254 let current = yield* observe({
255 id: output?.id,
256 name,
257 logicalId: id,
258 });
259
260 if (current === undefined) {
261 const created = yield* Hetzner.placementGroups
262 .createPlacementGroup({
263 name,
264 type,
265 labels: desired,
266 })
267 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
268 if (created?.action != null) {
269 yield* waitForAction(created.action.id);
270 }
271 current =
272 created?.placement_group ?? (yield* observe({ name, logicalId: id }));
273 }
274
275 if (current === undefined) {
276 return yield* new PlacementGroupNotResolved({ name });
277 }
278
279 const observedLabels = tagRecord(current.labels);
280 const { upsert, removed } = diffLabels(observedLabels, desired);
281 const nameChanged = current.name !== name;
282 const labelsChanged = upsert.length > 0 || removed.length > 0;
283 if (nameChanged || labelsChanged) {
284 const updated = yield* Hetzner.placementGroups.updatePlacementGroup({
285 id: current.id,
286 name: nameChanged ? name : undefined,
287 labels: labelsChanged ? desired : undefined,
288 });
289 current = updated.placement_group;
290 }
291
292 return toAttrs(current);
293 }),
294
295 delete: Effect.fn(function* ({ output }) {
296 yield* Hetzner.placementGroups
297 .deletePlacementGroup({ id: output.id })
298 .pipe(Effect.catchTag("NotFound", () => Effect.void));
299 }),
300 });