| 1 | import * as Hetzner from "@distilled.cloud/hetzner"; |
| 2 | import type { GetPrimaryIpResponsePrimaryIp } from "@distilled.cloud/hetzner/primary_ips"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import { Unowned } from "../AdoptPolicy.ts"; |
| 9 | import { isResolved } from "../Diff.ts"; |
| 10 | import { createPhysicalName } from "../PhysicalName.ts"; |
| 11 | import * as Provider from "../Provider.ts"; |
| 12 | import { Resource } from "../Resource.ts"; |
| 13 | import { tagRecord } from "../Tags.ts"; |
| 14 | import { waitForAction } from "./actions.ts"; |
| 15 | import { findLocation } from "./Catalog.ts"; |
| 16 | import { |
| 17 | alchemyStackSelector, |
| 18 | createInternalLabels, |
| 19 | diffLabels, |
| 20 | hasAlchemyLabels, |
| 21 | stripInternalLabels, |
| 22 | toLabels, |
| 23 | } from "./Labels.ts"; |
| 24 | import type { Providers } from "./Providers.ts"; |
| 25 | |
| 26 | export type PrimaryIpType = "ipv4" | "ipv6"; |
| 27 | |
| 28 | export interface PrimaryIpProps { |
| 29 | /** |
| 30 | * Address family. Cannot be changed after creation — changing it |
| 31 | * triggers a replacement. |
| 32 | */ |
| 33 | type: PrimaryIpType; |
| 34 | /** |
| 35 | * Location name (`nbg1`, `fsn1`, `hel1`, …) or numeric Location ID. |
| 36 | * Required unless `datacenter` is set. Cannot be changed after |
| 37 | * creation — changing it triggers a replacement. |
| 38 | */ |
| 39 | location?: string | number; |
| 40 | /** |
| 41 | * Datacenter name (`nbg1-dc3`, `fsn1-dc14`, …) or numeric Datacenter |
| 42 | * ID. Alternative to `location`; a name is mapped onto the parent |
| 43 | * Location (`nbg1-dc3` → `nbg1`). Cannot be changed after creation — |
| 44 | * changing it triggers a replacement. |
| 45 | */ |
| 46 | datacenter?: string | number; |
| 47 | /** |
| 48 | * Name of the Primary IP. Must be unique per Hetzner project. If |
| 49 | * omitted, a unique name is generated from the stack, stage, and |
| 50 | * logical ID. |
| 51 | */ |
| 52 | name?: string; |
| 53 | /** |
| 54 | * If enabled, Hetzner deletes this Primary IP when its assigned |
| 55 | * resource is deleted. |
| 56 | * @default false |
| 57 | */ |
| 58 | autoDelete?: boolean; |
| 59 | /** |
| 60 | * User-defined labels (`key`/`value` pairs) applied to the Primary IP. |
| 61 | * Alchemy ownership labels are added automatically. |
| 62 | */ |
| 63 | labels?: Record<string, string>; |
| 64 | /** |
| 65 | * Prevent the Primary IP from being deleted via the API. |
| 66 | * @default false |
| 67 | */ |
| 68 | deleteProtection?: boolean; |
| 69 | } |
| 70 | |
| 71 | export type PrimaryIp = Resource< |
| 72 | "Hetzner.PrimaryIp", |
| 73 | PrimaryIpProps, |
| 74 | { |
| 75 | /** |
| 76 | * Numeric Hetzner ID of the Primary IP. |
| 77 | */ |
| 78 | id: number; |
| 79 | /** |
| 80 | * Name of the Primary IP. |
| 81 | */ |
| 82 | name: string; |
| 83 | /** |
| 84 | * Address family of the Primary IP. |
| 85 | */ |
| 86 | type: PrimaryIpType; |
| 87 | /** |
| 88 | * Assigned address. For `ipv6` this is the `/64` network. |
| 89 | */ |
| 90 | ip: string; |
| 91 | /** |
| 92 | * Location name the IP is bound to (`nbg1`, `fsn1`, …). |
| 93 | */ |
| 94 | location: string; |
| 95 | /** |
| 96 | * Numeric Location id. |
| 97 | */ |
| 98 | locationId: number; |
| 99 | /** |
| 100 | * Datacenter name or id from props, when the resource was placed via |
| 101 | * `datacenter`. Not returned by the API. |
| 102 | */ |
| 103 | datacenter: string | undefined; |
| 104 | /** |
| 105 | * Whether Hetzner has blocked the Primary IP. |
| 106 | */ |
| 107 | blocked: boolean; |
| 108 | /** |
| 109 | * Auto-delete flag. |
| 110 | */ |
| 111 | autoDelete: boolean; |
| 112 | /** |
| 113 | * Assigned resource id, or `null` if unassigned. |
| 114 | */ |
| 115 | assigneeId: number | null; |
| 116 | /** |
| 117 | * Assigned resource type (`server`), when assigned. |
| 118 | */ |
| 119 | assigneeType: string | undefined; |
| 120 | /** |
| 121 | * RFC3339 timestamp when the Primary IP was created. |
| 122 | */ |
| 123 | created: string; |
| 124 | /** |
| 125 | * User-defined labels (Alchemy ownership labels are stripped). |
| 126 | */ |
| 127 | labels: Record<string, string>; |
| 128 | /** |
| 129 | * Whether delete protection is enabled. |
| 130 | */ |
| 131 | deleteProtection: boolean; |
| 132 | }, |
| 133 | never, |
| 134 | Providers |
| 135 | >; |
| 136 | |
| 137 | /** |
| 138 | * An unassigned Hetzner Cloud Primary IP. Provide `type` and either a |
| 139 | * `location` or a `datacenter`; Alchemy generates a unique name unless |
| 140 | * you set `name`. Assignee wiring is a later resource. |
| 141 | * |
| 142 | * `type`, `location`, and `datacenter` are immutable — changing any of |
| 143 | * them replaces the Primary IP (new address). `name`, `autoDelete`, |
| 144 | * `labels`, and `deleteProtection` update in place. |
| 145 | * |
| 146 | * @see https://docs.hetzner.cloud/reference/cloud#primary-ips |
| 147 | * |
| 148 | * ### Creating a Primary IP |
| 149 | * **Example:** IPv6 in Nuremberg |
| 150 | * ```typescript |
| 151 | * const ip = yield* Hetzner.PrimaryIp("web-ipv6", { |
| 152 | * type: "ipv6", |
| 153 | * location: "nbg1", |
| 154 | * }); |
| 155 | * ``` |
| 156 | * |
| 157 | * **Example:** IPv4 placed by datacenter |
| 158 | * ```typescript |
| 159 | * const ip = yield* Hetzner.PrimaryIp("web-ipv4", { |
| 160 | * type: "ipv4", |
| 161 | * datacenter: "nbg1-dc3", |
| 162 | * }); |
| 163 | * ``` |
| 164 | * |
| 165 | * ### Labels and auto-delete |
| 166 | * **Example:** Named IP with labels |
| 167 | * ```typescript |
| 168 | * const ip = yield* Hetzner.PrimaryIp("mail", { |
| 169 | * name: "mail-ipv4", |
| 170 | * type: "ipv4", |
| 171 | * location: "fsn1", |
| 172 | * autoDelete: false, |
| 173 | * labels: { role: "mail" }, |
| 174 | * }); |
| 175 | * ``` |
| 176 | * |
| 177 | * @resource |
| 178 | * @product IP Address |
| 179 | */ |
| 180 | export const PrimaryIp = Resource<PrimaryIp>("Hetzner.PrimaryIp"); |
| 181 | |
| 182 | export class PrimaryIpPlacementRequired extends Data.TaggedError( |
| 183 | "Hetzner.PrimaryIpPlacementRequired", |
| 184 | )<{ |
| 185 | message: string; |
| 186 | }> {} |
| 187 | |
| 188 | type CloudPrimaryIp = GetPrimaryIpResponsePrimaryIp; |
| 189 | |
| 190 | const asType = (type: string): PrimaryIpType => |
| 191 | type === "ipv6" ? "ipv6" : "ipv4"; |
| 192 | |
| 193 | const userLabels = ( |
| 194 | labels: Record<string, string | undefined> | null | undefined, |
| 195 | ): Record<string, string> => stripInternalLabels(tagRecord(labels)); |
| 196 | |
| 197 | /** |
| 198 | * Hetzner datacenter names are `{location}-dc{n}` (e.g. `nbg1-dc3`). |
| 199 | * Numeric ids are passed through — Locations use a different id space. |
| 200 | */ |
| 201 | export const locationFromDatacenter = ( |
| 202 | datacenter: string | number, |
| 203 | ): string | number => { |
| 204 | if (typeof datacenter === "number") return datacenter; |
| 205 | const match = /^([a-z0-9]+)-dc\d+$/i.exec(datacenter); |
| 206 | return match ? match[1]!.toLowerCase() : datacenter; |
| 207 | }; |
| 208 | |
| 209 | const resolvePlacement = ( |
| 210 | props: Pick<PrimaryIpProps, "location" | "datacenter">, |
| 211 | ): string | number | undefined => { |
| 212 | if (props.location !== undefined) return props.location; |
| 213 | if (props.datacenter !== undefined) { |
| 214 | return locationFromDatacenter(props.datacenter); |
| 215 | } |
| 216 | return undefined; |
| 217 | }; |
| 218 | |
| 219 | const samePlacement = ( |
| 220 | news: Pick<PrimaryIpProps, "location" | "datacenter">, |
| 221 | attrs: PrimaryIp["Attributes"], |
| 222 | ): boolean => { |
| 223 | const desired = resolvePlacement(news); |
| 224 | if (desired === undefined) return true; |
| 225 | return ( |
| 226 | desired === attrs.location || |
| 227 | desired === attrs.locationId || |
| 228 | String(desired) === String(attrs.locationId) |
| 229 | ); |
| 230 | }; |
| 231 | |
| 232 | const toAttrs = ( |
| 233 | ip: CloudPrimaryIp, |
| 234 | extras?: { datacenter?: string | number }, |
| 235 | ): PrimaryIp["Attributes"] => ({ |
| 236 | id: ip.id, |
| 237 | name: ip.name, |
| 238 | type: asType(ip.type), |
| 239 | ip: ip.ip, |
| 240 | location: ip.location.name, |
| 241 | locationId: ip.location.id, |
| 242 | datacenter: |
| 243 | extras?.datacenter !== undefined ? String(extras.datacenter) : undefined, |
| 244 | blocked: ip.blocked, |
| 245 | autoDelete: ip.auto_delete, |
| 246 | assigneeId: ip.assignee_id, |
| 247 | assigneeType: ip.assignee_type, |
| 248 | created: ip.created, |
| 249 | labels: userLabels(ip.labels), |
| 250 | deleteProtection: ip.protection.delete, |
| 251 | }); |
| 252 | |
| 253 | const createPrimaryIpName = ( |
| 254 | id: string, |
| 255 | name: string | undefined, |
| 256 | existing?: string, |
| 257 | ) => |
| 258 | Effect.gen(function* () { |
| 259 | return ( |
| 260 | name ?? existing ?? (yield* createPhysicalName({ id, maxLength: 63 })) |
| 261 | ); |
| 262 | }); |
| 263 | |
| 264 | const getById = (id: number) => |
| 265 | Hetzner.primaryIps.getPrimaryIp({ id }).pipe( |
| 266 | Effect.map(({ primary_ip }) => primary_ip), |
| 267 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 268 | ); |
| 269 | |
| 270 | const getByName = (name: string) => |
| 271 | Hetzner.primaryIps |
| 272 | .listPrimaryIps({ name, per_page: 50 }) |
| 273 | .pipe(Effect.map(({ primary_ips }) => primary_ips[0])); |
| 274 | |
| 275 | const observe = Effect.fn(function* ({ |
| 276 | name, |
| 277 | outputId, |
| 278 | }: { |
| 279 | id: string; |
| 280 | name?: string; |
| 281 | outputId?: number; |
| 282 | }) { |
| 283 | // Identity is the numeric id (cached on `output`) or the unique |
| 284 | // project-scoped name. Do NOT fall back to alchemy.* labels: a |
| 285 | // replacement shares the logical id with the old generation, so a |
| 286 | // label lookup would adopt the IP we are replacing. |
| 287 | if (outputId !== undefined) { |
| 288 | const byId = yield* getById(outputId); |
| 289 | if (byId !== undefined) return byId; |
| 290 | } |
| 291 | if (name !== undefined) { |
| 292 | return yield* getByName(name); |
| 293 | } |
| 294 | return undefined; |
| 295 | }); |
| 296 | |
| 297 | const refresh = (id: number) => |
| 298 | Hetzner.primaryIps.getPrimaryIp({ id }).pipe( |
| 299 | Effect.map(({ primary_ip }) => primary_ip), |
| 300 | Effect.retry({ |
| 301 | while: (e) => e._tag === "NotFound", |
| 302 | times: 5, |
| 303 | schedule: Schedule.min([ |
| 304 | Schedule.exponential(Duration.millis(200), 1.5), |
| 305 | Schedule.spaced(Duration.seconds(2)), |
| 306 | ]), |
| 307 | }), |
| 308 | ); |
| 309 | |
| 310 | const disableProtection = (id: number) => |
| 311 | Hetzner.primaryIpActions |
| 312 | .changePrimaryIpProtection({ id, delete: false }) |
| 313 | .pipe(Effect.flatMap(({ action }) => waitForAction(action))); |
| 314 | |
| 315 | export const PrimaryIpProvider = () => |
| 316 | Provider.succeed(PrimaryIp, { |
| 317 | stables: ["id", "ip", "type", "location", "locationId", "created"], |
| 318 | nuke: { dependsOn: ["Hetzner.Server"] }, |
| 319 | list: Effect.fn(function* () { |
| 320 | const items = yield* Hetzner.primaryIps.listPrimaryIps |
| 321 | .items({ label_selector: alchemyStackSelector, per_page: 50 }) |
| 322 | .pipe( |
| 323 | Stream.runCollect, |
| 324 | Effect.map((chunk) => Array.from(chunk)), |
| 325 | ); |
| 326 | return items.map((ip) => toAttrs(ip)); |
| 327 | }), |
| 328 | diff: Effect.fn(function* ({ news, output }) { |
| 329 | if (!isResolved(news)) return undefined; |
| 330 | if (output !== undefined) { |
| 331 | if (news.type !== output.type) { |
| 332 | return { action: "replace" } as const; |
| 333 | } |
| 334 | if (!samePlacement(news, output)) { |
| 335 | return { action: "replace" } as const; |
| 336 | } |
| 337 | } |
| 338 | return undefined; |
| 339 | }), |
| 340 | read: Effect.fn(function* ({ id, olds, output }) { |
| 341 | const found = yield* observe({ |
| 342 | id, |
| 343 | name: olds?.name ?? output?.name, |
| 344 | outputId: output?.id, |
| 345 | }); |
| 346 | if (found === undefined) return undefined; |
| 347 | const attrs = toAttrs(found, { |
| 348 | datacenter: olds?.datacenter ?? output?.datacenter, |
| 349 | }); |
| 350 | const owned = yield* hasAlchemyLabels(id, tagRecord(found.labels)); |
| 351 | return owned ? attrs : Unowned(attrs); |
| 352 | }), |
| 353 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 354 | const name = yield* createPrimaryIpName(id, news.name, output?.name); |
| 355 | const internalLabels = yield* createInternalLabels(id); |
| 356 | const desiredLabels = { |
| 357 | ...toLabels(news.labels), |
| 358 | ...internalLabels, |
| 359 | }; |
| 360 | const desiredAutoDelete = news.autoDelete ?? false; |
| 361 | const desiredProtection = news.deleteProtection ?? false; |
| 362 | |
| 363 | // Observe — cloud state is authoritative. `output.id` is a cache |
| 364 | // for the stable identifier; if the IP is gone, we recreate. |
| 365 | let current = yield* observe({ |
| 366 | id, |
| 367 | name, |
| 368 | outputId: output?.id, |
| 369 | }); |
| 370 | |
| 371 | // Ensure — create only when missing. A Conflict is a race with a |
| 372 | // peer reconciler or a name that just became visible; re-observe. |
| 373 | if (current === undefined) { |
| 374 | const placement = resolvePlacement(news); |
| 375 | if (placement === undefined) { |
| 376 | return yield* new PrimaryIpPlacementRequired({ |
| 377 | message: |
| 378 | "PrimaryIp requires `location` or `datacenter` when creating", |
| 379 | }); |
| 380 | } |
| 381 | const location = yield* findLocation(placement); |
| 382 | const created = yield* Hetzner.primaryIps |
| 383 | .createPrimaryIp({ |
| 384 | name, |
| 385 | type: news.type, |
| 386 | location: location.name, |
| 387 | labels: desiredLabels, |
| 388 | auto_delete: desiredAutoDelete, |
| 389 | }) |
| 390 | .pipe( |
| 391 | Effect.catchTag("Conflict", () => |
| 392 | observe({ id, name }).pipe( |
| 393 | Effect.flatMap((hit) => |
| 394 | hit !== undefined |
| 395 | ? Effect.succeed({ primary_ip: hit, action: undefined }) |
| 396 | : Hetzner.primaryIps.createPrimaryIp({ |
| 397 | name, |
| 398 | type: news.type, |
| 399 | location: location.name, |
| 400 | labels: desiredLabels, |
| 401 | auto_delete: desiredAutoDelete, |
| 402 | }), |
| 403 | ), |
| 404 | ), |
| 405 | ), |
| 406 | ); |
| 407 | if (created.action) { |
| 408 | yield* waitForAction(created.action); |
| 409 | } |
| 410 | current = created.primary_ip; |
| 411 | } |
| 412 | |
| 413 | // Sync — name / auto_delete / labels via PUT (labels overwrite the |
| 414 | // full set). Protection is a separate Action. |
| 415 | const observedLabels = tagRecord(current.labels); |
| 416 | const { upsert, removed } = diffLabels(observedLabels, desiredLabels); |
| 417 | const needsUpdate = |
| 418 | current.name !== name || |
| 419 | current.auto_delete !== desiredAutoDelete || |
| 420 | upsert.length > 0 || |
| 421 | removed.length > 0; |
| 422 | if (needsUpdate) { |
| 423 | const updated = yield* Hetzner.primaryIps.updatePrimaryIp({ |
| 424 | id: current.id, |
| 425 | name, |
| 426 | auto_delete: desiredAutoDelete, |
| 427 | labels: desiredLabels, |
| 428 | }); |
| 429 | current = updated.primary_ip; |
| 430 | } |
| 431 | |
| 432 | if (current.protection.delete !== desiredProtection) { |
| 433 | const { action } = |
| 434 | yield* Hetzner.primaryIpActions.changePrimaryIpProtection({ |
| 435 | id: current.id, |
| 436 | delete: desiredProtection, |
| 437 | }); |
| 438 | yield* waitForAction(action); |
| 439 | } |
| 440 | |
| 441 | return toAttrs(yield* refresh(current.id), { |
| 442 | datacenter: news.datacenter, |
| 443 | }); |
| 444 | }), |
| 445 | delete: Effect.fn(function* ({ output }) { |
| 446 | const current = yield* getById(output.id); |
| 447 | if (current === undefined) return; |
| 448 | if (current.protection.delete) { |
| 449 | yield* disableProtection(current.id); |
| 450 | } |
| 451 | if (current.assignee_id !== null) { |
| 452 | const { action } = yield* Hetzner.primaryIpActions.unassignPrimaryIp({ |
| 453 | id: current.id, |
| 454 | }); |
| 455 | yield* waitForAction(action); |
| 456 | } |
| 457 | yield* Hetzner.primaryIps |
| 458 | .deletePrimaryIp({ id: current.id }) |
| 459 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 460 | }), |
| 461 | }); |