@absolutejs/deploy 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,46 @@ address data, list/get/provision/terminate, and regional placement. Application
55
55
  deployment, draining, migration, and edge cutover remain higher-level
56
56
  orchestration concerns.
57
57
 
58
+ ## Global edge ingress (0.17.0)
59
+
60
+ `EdgeIngressProvider` is the shared lifecycle for a public global ingress over
61
+ regional edge pools. It normalizes the listener, backend health check, ordered
62
+ regional failover priority, provider resource references, addresses, state,
63
+ and idempotent removal. Provider resource construction stays here instead of
64
+ leaking DigitalOcean or GCP APIs into a control plane.
65
+
66
+ ```ts
67
+ import { createDigitalOceanIngressProvider } from '@absolutejs/deploy/digitalocean-ingress';
68
+
69
+ const ingress = createDigitalOceanIngressProvider({
70
+ token: process.env.DIGITALOCEAN_TOKEN!,
71
+ });
72
+
73
+ await ingress.reconcileIngress({
74
+ name: 'absolutejs-edge',
75
+ idempotencyKey: crypto.randomUUID(),
76
+ backends: [
77
+ { region: 'nyc3', resourceId: 'regional-lb-east', priority: 1 },
78
+ { region: 'sfo3', resourceId: 'regional-lb-west', priority: 2 },
79
+ ],
80
+ listener: {
81
+ port: 443,
82
+ protocol: 'https',
83
+ targetPort: 443,
84
+ tlsPassthrough: true,
85
+ },
86
+ healthCheck: { protocol: 'tcp', port: 443 },
87
+ });
88
+ ```
89
+
90
+ DigitalOcean uses a Global Load Balancer whose backends are regional load
91
+ balancer UUIDs. GCP uses a global external TCP proxy whose backends are
92
+ provider-native instance-group or NEG self-links; the adapter also owns its
93
+ health check, backend service, proxy, Premium address, and forwarding rule.
94
+ Both preserve TLS termination at the regional edge and wait for provider
95
+ operations before advancing dependent resources. Creating an adapter does not
96
+ provision anything; only `reconcileIngress()` mutates provider state.
97
+
58
98
  ```ts
59
99
  import {
60
100
  createDeployer,
@@ -0,0 +1,9 @@
1
+ /** DigitalOcean Global Load Balancer adapter for regional edge pools. */
2
+ import { type DigitalOceanClientLike } from "./digitalocean";
3
+ import { type EdgeIngressProvider } from "./edgeIngress";
4
+ export type DigitalOceanIngressProviderOptions = {
5
+ client?: DigitalOceanClientLike;
6
+ networkStack?: "IPV4" | "DUALSTACK";
7
+ token?: string;
8
+ };
9
+ export declare const createDigitalOceanIngressProvider: (options: DigitalOceanIngressProviderOptions) => EdgeIngressProvider;
@@ -0,0 +1,552 @@
1
+ // @bun
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ function __accessProp(key) {
8
+ return this[key];
9
+ }
10
+ var __toESMCache_node;
11
+ var __toESMCache_esm;
12
+ var __toESM = (mod, isNodeMode, target) => {
13
+ var canCache = mod != null && typeof mod === "object";
14
+ if (canCache) {
15
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
16
+ var cached = cache.get(mod);
17
+ if (cached)
18
+ return cached;
19
+ }
20
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
21
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
22
+ for (let key of __getOwnPropNames(mod))
23
+ if (!__hasOwnProp.call(to, key))
24
+ __defProp(to, key, {
25
+ get: __accessProp.bind(mod, key),
26
+ enumerable: true
27
+ });
28
+ if (canCache)
29
+ cache.set(mod, to);
30
+ return to;
31
+ };
32
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
33
+ var __require = import.meta.require;
34
+
35
+ // src/edgeIngress.ts
36
+ var validPort = (port) => Number.isInteger(port) && port >= 1 && port <= 65535;
37
+
38
+ class EdgeIngressValidationError extends Error {
39
+ }
40
+ var validateEdgeIngressSpec = (spec) => {
41
+ if (!/^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(spec.name))
42
+ throw new EdgeIngressValidationError("Invalid edge ingress name");
43
+ if (spec.backends.length === 0)
44
+ throw new EdgeIngressValidationError("Edge ingress requires at least one regional backend");
45
+ if (!validPort(spec.listener.port) || !validPort(spec.listener.targetPort))
46
+ throw new EdgeIngressValidationError("Invalid edge ingress listener port");
47
+ if (!validPort(spec.healthCheck.port))
48
+ throw new EdgeIngressValidationError("Invalid edge ingress health port");
49
+ const resources = new Set;
50
+ for (const backend of spec.backends) {
51
+ if (!backend.resourceId || !backend.region)
52
+ throw new EdgeIngressValidationError("Invalid edge ingress backend");
53
+ if (resources.has(backend.resourceId))
54
+ throw new EdgeIngressValidationError("Duplicate edge ingress backend");
55
+ resources.add(backend.resourceId);
56
+ }
57
+ if (spec.listener.tlsPassthrough && spec.listener.protocol !== "https" && spec.listener.protocol !== "tcp")
58
+ throw new EdgeIngressValidationError("TLS passthrough requires an HTTPS or TCP listener");
59
+ return spec;
60
+ };
61
+ var normalizedEdgeIngressBackends = (backends) => [...backends].sort((left, right) => (left.priority ?? Number.MAX_SAFE_INTEGER) - (right.priority ?? Number.MAX_SAFE_INTEGER) || left.region.localeCompare(right.region) || left.resourceId.localeCompare(right.resourceId));
62
+
63
+ // src/targets.ts
64
+ import { mkdir } from "fs/promises";
65
+ import { join } from "path";
66
+ var decodeChunks = async (reader, onLine) => {
67
+ if (!reader)
68
+ return "";
69
+ const decoder = new TextDecoder;
70
+ let buffer = "";
71
+ let collected = "";
72
+ const stream = reader.getReader();
73
+ try {
74
+ while (true) {
75
+ const { done, value } = await stream.read();
76
+ if (done)
77
+ break;
78
+ const chunk = decoder.decode(value, { stream: true });
79
+ collected += chunk;
80
+ if (!onLine)
81
+ continue;
82
+ buffer += chunk;
83
+ let newline = buffer.indexOf(`
84
+ `);
85
+ while (newline !== -1) {
86
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
87
+ if (line.length > 0)
88
+ onLine(line);
89
+ buffer = buffer.slice(newline + 1);
90
+ newline = buffer.indexOf(`
91
+ `);
92
+ }
93
+ }
94
+ const tail = decoder.decode();
95
+ collected += tail;
96
+ if (onLine && (buffer + tail).length > 0)
97
+ onLine((buffer + tail).replace(/\r$/, ""));
98
+ } finally {
99
+ stream.releaseLock();
100
+ }
101
+ return collected;
102
+ };
103
+ var runSpawn = async (argv, options) => {
104
+ const proc = Bun.spawn(argv, {
105
+ cwd: options.cwd,
106
+ env: options.env,
107
+ stderr: "pipe",
108
+ stdin: options.stdin === undefined ? "ignore" : "pipe",
109
+ stdout: "pipe"
110
+ });
111
+ if (options.stdin !== undefined && proc.stdin) {
112
+ const sink = proc.stdin;
113
+ const wrote = sink.write(options.stdin);
114
+ if (wrote && typeof wrote.then === "function") {
115
+ await wrote;
116
+ }
117
+ const ended = sink.end();
118
+ if (ended && typeof ended.then === "function") {
119
+ await ended;
120
+ }
121
+ }
122
+ const timeout = options.timeoutMs ?? 600000;
123
+ let timer;
124
+ if (timeout > 0) {
125
+ timer = setTimeout(() => {
126
+ try {
127
+ proc.kill();
128
+ } catch {}
129
+ }, timeout);
130
+ }
131
+ const stdoutPromise = decodeChunks(proc.stdout, options.onLog ? (line) => options.onLog(line, "stdout") : undefined);
132
+ const stderrPromise = decodeChunks(proc.stderr, options.onLog ? (line) => options.onLog(line, "stderr") : undefined);
133
+ const [stdout, stderr, exitCode] = await Promise.all([
134
+ stdoutPromise,
135
+ stderrPromise,
136
+ proc.exited
137
+ ]);
138
+ if (timer)
139
+ clearTimeout(timer);
140
+ return { exitCode: exitCode ?? -1, stderr, stdout };
141
+ };
142
+ var localTarget = (options) => {
143
+ const baseEnv = { ...options.env };
144
+ const ensureRoot = async () => {
145
+ await mkdir(options.root, { recursive: true });
146
+ };
147
+ return {
148
+ description: `local ${options.root}`,
149
+ exec: async (cmd, opts) => {
150
+ await ensureRoot();
151
+ return runSpawn(["sh", "-c", cmd], {
152
+ cwd: opts?.cwd ?? options.root,
153
+ env: { ...process.env, ...baseEnv, ...opts?.env ?? {} },
154
+ onLog: opts?.onLog,
155
+ stdin: opts?.stdin,
156
+ timeoutMs: opts?.timeoutMs
157
+ });
158
+ },
159
+ upload: async (localPath, remotePath, opts) => {
160
+ await ensureRoot();
161
+ const dest = remotePath.startsWith("/") ? remotePath : join(options.root, remotePath);
162
+ const argv = ["rsync", "-a"];
163
+ if (opts?.deleteOrphans)
164
+ argv.push("--delete");
165
+ for (const pattern of opts?.exclude ?? [])
166
+ argv.push("--exclude", pattern);
167
+ argv.push(localPath, dest);
168
+ const result = await runSpawn(argv, { timeoutMs: 600000 });
169
+ if (result.exitCode !== 0) {
170
+ throw new Error(`local upload failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
171
+ }
172
+ }
173
+ };
174
+ };
175
+ var sshTargetString = (options) => {
176
+ const user = options.user ?? "root";
177
+ return `${user}@${options.host}`;
178
+ };
179
+ var sshBaseFlags = (options) => {
180
+ const flags = [];
181
+ if (options.port !== undefined && options.port !== 22)
182
+ flags.push("-p", String(options.port));
183
+ if (options.identity !== undefined)
184
+ flags.push("-i", options.identity);
185
+ flags.push("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new");
186
+ for (const flag of options.sshFlags ?? [])
187
+ flags.push(flag);
188
+ return flags;
189
+ };
190
+ var shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
191
+ var buildRemoteCmd = (cmd, opts) => {
192
+ const env = opts?.env;
193
+ const envPrefix = env ? Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ") + " " : "";
194
+ if (opts?.cwd) {
195
+ return `cd ${shellQuote(opts.cwd)} && ${envPrefix}${cmd}`;
196
+ }
197
+ return `${envPrefix}${cmd}`;
198
+ };
199
+ var sshTarget = (options) => {
200
+ const remote = sshTargetString(options);
201
+ const useRsync = options.rsync ?? true;
202
+ return {
203
+ description: `ssh ${remote}${options.port && options.port !== 22 ? `:${options.port}` : ""}`,
204
+ exec: async (cmd, opts) => {
205
+ const argv = ["ssh", ...sshBaseFlags(options)];
206
+ for (const name of options.forwardEnv ?? [])
207
+ argv.push("-o", `SendEnv=${name}`);
208
+ argv.push(remote, buildRemoteCmd(cmd, opts));
209
+ return runSpawn(argv, {
210
+ onLog: opts?.onLog,
211
+ stdin: opts?.stdin,
212
+ timeoutMs: opts?.timeoutMs
213
+ });
214
+ },
215
+ upload: async (localPath, remotePath, opts) => {
216
+ if (useRsync) {
217
+ const sshCmd = ["ssh", ...sshBaseFlags(options)].map((part) => part.includes(" ") ? `'${part}'` : part).join(" ");
218
+ const argv2 = ["rsync", "-az", "-e", sshCmd];
219
+ if (opts?.deleteOrphans)
220
+ argv2.push("--delete");
221
+ for (const pattern of opts?.exclude ?? [])
222
+ argv2.push("--exclude", pattern);
223
+ argv2.push(localPath, `${remote}:${remotePath}`);
224
+ const result2 = await runSpawn(argv2, { timeoutMs: 600000 });
225
+ if (result2.exitCode !== 0) {
226
+ throw new Error(`rsync upload failed (exit ${result2.exitCode}): ${result2.stderr || result2.stdout}`);
227
+ }
228
+ return;
229
+ }
230
+ const argv = ["scp", "-r", ...sshBaseFlags(options), localPath, `${remote}:${remotePath}`];
231
+ const result = await runSpawn(argv, { timeoutMs: 600000 });
232
+ if (result.exitCode !== 0) {
233
+ throw new Error(`scp upload failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
234
+ }
235
+ }
236
+ };
237
+ };
238
+
239
+ // src/cloudTarget.ts
240
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
241
+ var defaultProbeSsh = async (host, port) => {
242
+ const PROBE_TIMEOUT_MS = 2000;
243
+ return new Promise((resolve) => {
244
+ let settled = false;
245
+ const settle = (value) => {
246
+ if (settled)
247
+ return;
248
+ settled = true;
249
+ resolve(value);
250
+ };
251
+ const timer = setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
252
+ Bun.connect({
253
+ hostname: host,
254
+ port,
255
+ socket: {
256
+ data: () => {},
257
+ error: () => {
258
+ clearTimeout(timer);
259
+ settle(false);
260
+ },
261
+ open: (socket) => {
262
+ clearTimeout(timer);
263
+ socket.end();
264
+ settle(true);
265
+ }
266
+ }
267
+ }).catch(() => {
268
+ clearTimeout(timer);
269
+ settle(false);
270
+ });
271
+ });
272
+ };
273
+ var createCloudTarget = async (hooks, options) => {
274
+ const log = options.onLog ?? (() => {});
275
+ const probeSsh = options.probeSsh ?? defaultProbeSsh;
276
+ const sleep = options.sleep ?? defaultSleep;
277
+ const now = options.now ?? Date.now;
278
+ const pollMs = options.pollIntervalMs ?? 5000;
279
+ const provisionTimeout = options.provisionTimeoutMs ?? 5 * 60000;
280
+ const sshTimeout = options.sshReadinessTimeoutMs ?? 2 * 60000;
281
+ const port = options.port ?? 22;
282
+ const prefix = options.logPrefix;
283
+ const noun = options.entityWord;
284
+ const existing = await hooks.findByName(options.name);
285
+ let current;
286
+ if (existing === undefined) {
287
+ log(`${prefix} creating ${noun} "${options.name}" in ${options.region}`);
288
+ current = await hooks.create();
289
+ } else {
290
+ log(`${prefix} reusing ${noun} "${options.name}" (id ${hooks.getId(existing)}, status ${hooks.getStatus(existing)})`);
291
+ current = existing;
292
+ }
293
+ const provisionStart = now();
294
+ let ipv4 = hooks.getIpv4(current);
295
+ while (!hooks.isReady(current) || ipv4 === undefined) {
296
+ if (now() - provisionStart > provisionTimeout) {
297
+ throw new Error(`${prefix} provision timeout after ${provisionTimeout}ms \u2014 ${noun} ${hooks.getId(current)} status "${hooks.getStatus(current)}", ipv4 ${ipv4 ?? "(unassigned)"}`);
298
+ }
299
+ await sleep(pollMs);
300
+ current = await hooks.fetch(hooks.getId(current));
301
+ ipv4 = hooks.getIpv4(current);
302
+ log(`${prefix} poll: status=${hooks.getStatus(current)} ipv4=${ipv4 ?? "(none yet)"}`);
303
+ }
304
+ log(`${prefix} ${noun} ready at ${ipv4}`);
305
+ const sshStart = now();
306
+ while (!await probeSsh(ipv4, port)) {
307
+ if (now() - sshStart > sshTimeout) {
308
+ throw new Error(`${prefix} SSH readiness timeout after ${sshTimeout}ms \u2014 ${ipv4}:${port} did not accept connections`);
309
+ }
310
+ await sleep(pollMs);
311
+ log(`${prefix} waiting on ssh ${ipv4}:${port}`);
312
+ }
313
+ log(`${prefix} ssh ready at ${ipv4}:${port}`);
314
+ const ssh = sshTarget({
315
+ host: ipv4,
316
+ ...options.user !== undefined ? { user: options.user } : {},
317
+ ...options.identity !== undefined ? { identity: options.identity } : {},
318
+ ...options.port !== undefined ? { port: options.port } : {}
319
+ });
320
+ const id = hooks.getId(current);
321
+ const resolvedIpv4 = ipv4;
322
+ return {
323
+ description: options.describeTarget(ssh.description),
324
+ destroy: () => hooks.destroy(id).then(() => {
325
+ log(`${prefix} destroyed ${noun} ${id}`);
326
+ }),
327
+ exec: ssh.exec,
328
+ id,
329
+ ipv4: resolvedIpv4,
330
+ upload: ssh.upload,
331
+ ...ssh.close !== undefined ? { close: ssh.close } : {}
332
+ };
333
+ };
334
+
335
+ // src/digitalocean.ts
336
+ var DO_API_BASE = "https://api.digitalocean.com/v2";
337
+
338
+ class DigitalOceanError extends Error {
339
+ status;
340
+ body;
341
+ constructor(message, status, body) {
342
+ super(message);
343
+ this.name = "DigitalOceanError";
344
+ this.status = status;
345
+ this.body = body;
346
+ }
347
+ }
348
+ var createDigitalOceanClient = (token, options = {}) => {
349
+ const base = options.baseUrl ?? DO_API_BASE;
350
+ const f = options.fetch ?? fetch;
351
+ return {
352
+ request: async (method, path, body) => {
353
+ const init = {
354
+ headers: {
355
+ authorization: `Bearer ${token}`,
356
+ "content-type": "application/json"
357
+ },
358
+ method
359
+ };
360
+ if (body !== undefined)
361
+ init.body = JSON.stringify(body);
362
+ const response = await f(`${base}${path}`, init);
363
+ if (response.status === 204)
364
+ return;
365
+ const text = await response.text();
366
+ const parsed = text.length > 0 ? JSON.parse(text) : undefined;
367
+ if (!response.ok) {
368
+ throw new DigitalOceanError(`DigitalOcean API ${method} ${path} failed: ${response.status} ${response.statusText}`, response.status, parsed);
369
+ }
370
+ return parsed;
371
+ }
372
+ };
373
+ };
374
+ var resolveClient = (options) => {
375
+ if (options.client !== undefined)
376
+ return options.client;
377
+ if (options.token !== undefined && options.token.length > 0) {
378
+ return createDigitalOceanClient(options.token);
379
+ }
380
+ throw new Error("[deploy/digitalocean] either `token` or `client` must be provided");
381
+ };
382
+ var publicIpv4 = (droplet) => droplet.networks.v4.find((net) => net.type === "public")?.ip_address;
383
+ var findDigitalOceanDroplet = async (client, name) => {
384
+ const body = await client.request("GET", `/droplets?name=${encodeURIComponent(name)}`);
385
+ const matches = body.droplets.filter((droplet) => droplet.name === name);
386
+ if (matches.length === 0)
387
+ return;
388
+ if (matches.length > 1) {
389
+ throw new Error(`[deploy/digitalocean] multiple droplets named "${name}" (${matches.map((droplet) => droplet.id).join(", ")}). Resolve manually before adopting.`);
390
+ }
391
+ return matches[0];
392
+ };
393
+ var listDigitalOceanDroplets = async (options) => {
394
+ const client = resolveClient(options);
395
+ const path = options.tag !== undefined ? `/droplets?tag_name=${encodeURIComponent(options.tag)}` : "/droplets";
396
+ const body = await client.request("GET", path);
397
+ return body.droplets;
398
+ };
399
+ var destroyDigitalOceanDroplet = async (options) => {
400
+ const client = resolveClient(options);
401
+ try {
402
+ await client.request("DELETE", `/droplets/${options.id}`);
403
+ } catch (error) {
404
+ if (error instanceof DigitalOceanError && error.status === 404) {
405
+ return;
406
+ }
407
+ throw error;
408
+ }
409
+ };
410
+ var digitalOceanTarget = async (options) => {
411
+ const client = resolveClient(options);
412
+ const hooks = {
413
+ create: async () => {
414
+ const created = await client.request("POST", "/droplets", {
415
+ name: options.name,
416
+ region: options.region,
417
+ size: options.size,
418
+ image: options.image,
419
+ ssh_keys: [...options.sshKeys],
420
+ ...options.tags !== undefined ? { tags: [...options.tags] } : {},
421
+ ...options.userData !== undefined ? { user_data: options.userData } : {},
422
+ ...options.vpcUuid !== undefined ? { vpc_uuid: options.vpcUuid } : {},
423
+ ...options.ipv6 === true ? { ipv6: true } : {},
424
+ ...options.monitoring === true ? { monitoring: true } : {}
425
+ });
426
+ return created.droplet;
427
+ },
428
+ destroy: (id) => destroyDigitalOceanDroplet({ client, id }),
429
+ fetch: async (id) => {
430
+ const refreshed = await client.request("GET", `/droplets/${id}`);
431
+ return refreshed.droplet;
432
+ },
433
+ findByName: (name) => findDigitalOceanDroplet(client, name),
434
+ getId: (droplet) => droplet.id,
435
+ getIpv4: publicIpv4,
436
+ getStatus: (droplet) => droplet.status,
437
+ isReady: (droplet) => droplet.status === "active"
438
+ };
439
+ const result = await createCloudTarget(hooks, {
440
+ describeTarget: (sshDescription) => `digitalocean droplet "${options.name}" (${sshDescription})`,
441
+ entityWord: "droplet",
442
+ logPrefix: "[do]",
443
+ name: options.name,
444
+ region: options.region,
445
+ ...options.user !== undefined ? { user: options.user } : {},
446
+ ...options.identity !== undefined ? { identity: options.identity } : {},
447
+ ...options.port !== undefined ? { port: options.port } : {},
448
+ ...options.provisionTimeoutMs !== undefined ? { provisionTimeoutMs: options.provisionTimeoutMs } : {},
449
+ ...options.sshReadinessTimeoutMs !== undefined ? { sshReadinessTimeoutMs: options.sshReadinessTimeoutMs } : {},
450
+ ...options.pollIntervalMs !== undefined ? { pollIntervalMs: options.pollIntervalMs } : {},
451
+ ...options.onLog !== undefined ? { onLog: options.onLog } : {},
452
+ ...options.probeSsh !== undefined ? { probeSsh: options.probeSsh } : {},
453
+ ...options.sleep !== undefined ? { sleep: options.sleep } : {},
454
+ ...options.now !== undefined ? { now: options.now } : {}
455
+ });
456
+ return {
457
+ description: result.description,
458
+ destroy: result.destroy,
459
+ dropletId: result.id,
460
+ exec: result.exec,
461
+ ipv4: result.ipv4,
462
+ upload: result.upload,
463
+ ...result.close !== undefined ? { close: result.close } : {}
464
+ };
465
+ };
466
+
467
+ // src/digitaloceanIngress.ts
468
+ var resolveClient2 = (options) => {
469
+ if (options.client)
470
+ return options.client;
471
+ if (options.token)
472
+ return createDigitalOceanClient(options.token);
473
+ throw new Error("[deploy/digitalocean-ingress] either `token` or `client` must be provided");
474
+ };
475
+ var normalize = (loadBalancer, backends = []) => ({
476
+ addresses: [loadBalancer.ip, loadBalancer.ipv6].filter((address) => Boolean(address)),
477
+ backends: normalizedEdgeIngressBackends(backends),
478
+ id: `digitalocean:${loadBalancer.id}`,
479
+ name: loadBalancer.name,
480
+ provider: "digitalocean",
481
+ state: loadBalancer.status === "active" ? "ready" : loadBalancer.status === "errored" ? "degraded" : "provisioning"
482
+ });
483
+ var createDigitalOceanIngressProvider = (options) => {
484
+ const client = resolveClient2(options);
485
+ const find = async (name) => {
486
+ const response = await client.request("GET", "/load_balancers");
487
+ const matches = response.load_balancers.filter((loadBalancer) => loadBalancer.name === name);
488
+ if (matches.length > 1)
489
+ throw new Error(`[deploy/digitalocean-ingress] multiple load balancers named ${name}`);
490
+ return matches[0] ?? null;
491
+ };
492
+ return {
493
+ capabilities: {
494
+ automaticHealthFailover: true,
495
+ global: true,
496
+ tlsPassthrough: true
497
+ },
498
+ getIngress: async (name) => {
499
+ const existing = await find(name);
500
+ return existing ? normalize(existing) : null;
501
+ },
502
+ name: "digitalocean",
503
+ reconcileIngress: async (spec) => {
504
+ validateEdgeIngressSpec(spec);
505
+ if (spec.listener.protocol === "tcp")
506
+ throw new Error("[deploy/digitalocean-ingress] Global Load Balancers require an HTTP-family listener");
507
+ const backends = normalizedEdgeIngressBackends(spec.backends);
508
+ const existing = await find(spec.name);
509
+ const body = {
510
+ forwarding_rules: [
511
+ {
512
+ entry_port: spec.listener.port,
513
+ entry_protocol: spec.listener.protocol,
514
+ target_port: spec.listener.targetPort,
515
+ target_protocol: spec.listener.protocol,
516
+ ...spec.listener.tlsPassthrough ? { tls_passthrough: true } : {}
517
+ }
518
+ ],
519
+ glb_settings: {
520
+ region_priorities: Object.fromEntries(backends.map((backend, index) => [
521
+ backend.region,
522
+ backend.priority ?? index + 1
523
+ ])),
524
+ target_load_balancer_ids: backends.map((backend) => backend.resourceId),
525
+ target_port: spec.listener.targetPort,
526
+ target_protocol: spec.listener.protocol
527
+ },
528
+ health_check: {
529
+ path: spec.healthCheck.path ?? "/healthz",
530
+ port: spec.healthCheck.port,
531
+ protocol: spec.healthCheck.protocol
532
+ },
533
+ name: spec.name,
534
+ network_stack: options.networkStack ?? "DUALSTACK",
535
+ type: "GLOBAL"
536
+ };
537
+ const response = existing ? await client.request("PUT", `/load_balancers/${encodeURIComponent(existing.id)}`, body) : await client.request("POST", "/load_balancers", body);
538
+ return normalize(response.load_balancer, backends);
539
+ },
540
+ removeIngress: async (name) => {
541
+ const existing = await find(name);
542
+ if (existing)
543
+ await client.request("DELETE", `/load_balancers/${encodeURIComponent(existing.id)}`);
544
+ }
545
+ };
546
+ };
547
+ export {
548
+ createDigitalOceanIngressProvider
549
+ };
550
+
551
+ //# debugId=C41516659DD2857764756E2164756E21
552
+ //# sourceMappingURL=digitaloceanIngress.js.map