@wtfalch/foundry 0.19.1 → 0.20.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/dist/bootstrap/declare.js +8 -6
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/diff.js +4 -4
- package/dist/plan/edge-access.d.ts +68 -0
- package/dist/plan/edge-access.js +123 -0
- package/dist/plan/edge-dns.d.ts +89 -0
- package/dist/plan/edge-dns.js +227 -0
- package/dist/plan/edge-ratelimits.d.ts +54 -0
- package/dist/plan/edge-ratelimits.js +79 -0
- package/dist/plan/edge-tunnels.d.ts +44 -0
- package/dist/plan/edge-tunnels.js +108 -0
- package/dist/plan/edge-workers.d.ts +31 -0
- package/dist/plan/edge-workers.js +165 -0
- package/dist/plan/edge-zones.d.ts +37 -0
- package/dist/plan/edge-zones.js +64 -0
- package/dist/plan/entry-edge-key.d.ts +10 -0
- package/dist/plan/entry-edge-key.js +38 -8
- package/dist/plan/executor.d.ts +112 -143
- package/dist/plan/executor.js +189 -87
- package/dist/plan/index.d.ts +15 -3
- package/dist/plan/index.js +8 -2
- package/dist/plan/plan-apply.js +5 -1
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +32 -8
- package/dist/plan/platform-providers.d.ts +53 -6
- package/dist/plan/platform-providers.js +87 -44
- package/dist/plan/providers-readers.d.ts +23 -1
- package/dist/plan/providers-readers.js +29 -0
- package/package.json +2 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { createEdgeClient } from '@wtfalch/edge';
|
|
2
|
+
import { type WriteSlot } from './edge-dns.js';
|
|
3
|
+
/** The edge SDK's platform rate limiting client: `createEdgeClient({ ... }).platform.ratelimits`. */
|
|
4
|
+
export type EdgePlatformRateLimits = ReturnType<typeof createEdgeClient>['platform']['ratelimits'];
|
|
5
|
+
/** One rate limiting rule, in the shape foundry has always sent Cloudflare. */
|
|
6
|
+
export interface RateLimitRuleInput {
|
|
7
|
+
description: string;
|
|
8
|
+
expression: string;
|
|
9
|
+
action: string;
|
|
10
|
+
ratelimit: {
|
|
11
|
+
characteristics: string[];
|
|
12
|
+
period: number;
|
|
13
|
+
requests_per_period: number;
|
|
14
|
+
mitigation_timeout: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Foundry's Cloudflare rate limiting calls. Every one goes through this; the
|
|
19
|
+
* one implementation is {@link createEdgeRateLimits}, which speaks to the edge
|
|
20
|
+
* service with the platform credential.
|
|
21
|
+
*/
|
|
22
|
+
export interface RateLimitPort {
|
|
23
|
+
/** Every live rule in the zone, whoever owns it. */
|
|
24
|
+
listRateLimitRules(zoneId: string): Promise<Array<{
|
|
25
|
+
id: string;
|
|
26
|
+
description: string | null;
|
|
27
|
+
}>>;
|
|
28
|
+
/** Makes the rule, or returns the one already made for that zone and description. Never changes an existing rule. */
|
|
29
|
+
createRateLimitRule(zoneId: string, rule: RateLimitRuleInput): Promise<{
|
|
30
|
+
id: string;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
/** Edge answers a create another create of the same rule is making `conflict` + `busy`. */
|
|
34
|
+
export declare const EDGE_RATELIMITS_BUSY_ATTEMPTS = 5;
|
|
35
|
+
export declare const hasReason: (error: unknown, reason: string) => boolean;
|
|
36
|
+
/** Waits 1 s, 2 s, 4 s, then 8 s (the cap) between tries of a `busy` answer. */
|
|
37
|
+
export declare function busyRetrier(sleepOption?: (ms: number) => Promise<void>): <T>(task: () => Promise<T>) => Promise<T>;
|
|
38
|
+
export interface EdgeRateLimitsOptions {
|
|
39
|
+
sleep?: (ms: number) => Promise<void>;
|
|
40
|
+
/** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the other edge adapters: edge counts them all. */
|
|
41
|
+
writeSlot?: WriteSlot;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* {@link RateLimitPort} over edge's `platform.ratelimits`.
|
|
45
|
+
*
|
|
46
|
+
* Edge answers a create of a live rule that has no edge row `exists`. So the
|
|
47
|
+
* adapter adopts that rule the moment it is about to create it: it lists the
|
|
48
|
+
* description, and adopts a view whose `owner` is `null`. Adopt is idempotent
|
|
49
|
+
* and the list reports the owner afterwards, so nothing is recorded in the
|
|
50
|
+
* plan. A rule edge reports as `taken` is an error. Create is keyed by zone
|
|
51
|
+
* and description and never compares content, so a rule that exists comes back
|
|
52
|
+
* unchanged. A `rate_limited` or `busy` answer is retried.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createEdgeRateLimits(client: EdgePlatformRateLimits, options?: EdgeRateLimitsOptions): RateLimitPort;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
2
|
+
import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
|
|
3
|
+
/** Edge answers a create another create of the same rule is making `conflict` + `busy`. */
|
|
4
|
+
export const EDGE_RATELIMITS_BUSY_ATTEMPTS = 5;
|
|
5
|
+
const BUSY_FIRST_DELAY_MS = 1000;
|
|
6
|
+
const BUSY_MAX_DELAY_MS = 8000;
|
|
7
|
+
export const hasReason = (error, reason) => error instanceof EdgeApiError && error.code === 'conflict' && String(error.reason) === reason;
|
|
8
|
+
/** Waits 1 s, 2 s, 4 s, then 8 s (the cap) between tries of a `busy` answer. */
|
|
9
|
+
export function busyRetrier(sleepOption) {
|
|
10
|
+
const sleep = sleepOption ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
11
|
+
return async (task) => {
|
|
12
|
+
for (let attempt = 1;; attempt++) {
|
|
13
|
+
try {
|
|
14
|
+
return await task();
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (!hasReason(error, 'busy') || attempt >= EDGE_RATELIMITS_BUSY_ATTEMPTS)
|
|
18
|
+
throw error;
|
|
19
|
+
await sleep(Math.min(BUSY_FIRST_DELAY_MS * 2 ** (attempt - 1), BUSY_MAX_DELAY_MS));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* {@link RateLimitPort} over edge's `platform.ratelimits`.
|
|
26
|
+
*
|
|
27
|
+
* Edge answers a create of a live rule that has no edge row `exists`. So the
|
|
28
|
+
* adapter adopts that rule the moment it is about to create it: it lists the
|
|
29
|
+
* description, and adopts a view whose `owner` is `null`. Adopt is idempotent
|
|
30
|
+
* and the list reports the owner afterwards, so nothing is recorded in the
|
|
31
|
+
* plan. A rule edge reports as `taken` is an error. Create is keyed by zone
|
|
32
|
+
* and description and never compares content, so a rule that exists comes back
|
|
33
|
+
* unchanged. A `rate_limited` or `busy` answer is retried.
|
|
34
|
+
*/
|
|
35
|
+
export function createEdgeRateLimits(client, options = {}) {
|
|
36
|
+
const rateRetry = rateLimitRetrier(options.sleep);
|
|
37
|
+
const busyRetry = busyRetrier(options.sleep);
|
|
38
|
+
const retrying = (task) => busyRetry(() => rateRetry(task));
|
|
39
|
+
const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
40
|
+
/** `zoneId/description` keys already adopted (or created) through this adapter. */
|
|
41
|
+
const adopted = new Set();
|
|
42
|
+
const adoptLive = async (zoneId, description) => {
|
|
43
|
+
const key = `${zoneId}/${description}`;
|
|
44
|
+
if (adopted.has(key))
|
|
45
|
+
return;
|
|
46
|
+
const { rules } = await retrying(() => client.list({ zoneId, description }));
|
|
47
|
+
const view = rules.find((r) => r.description === description && r.state !== 'removed');
|
|
48
|
+
if (view && view.owner === null) {
|
|
49
|
+
const result = await writeSlot(() => retrying(() => client.adopt({ rules: [{ zoneId, description, id: view.id }] })));
|
|
50
|
+
if (result.skipped.length > 0) {
|
|
51
|
+
throw new Error(`edge rate limits: rule '${description}' in zone ${zoneId} cannot be adopted (${result.skipped[0]?.reason}); remove or fix it by hand`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
adopted.add(key);
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
async listRateLimitRules(zoneId) {
|
|
58
|
+
const { rules } = await retrying(() => client.list({ zoneId }));
|
|
59
|
+
return rules
|
|
60
|
+
.filter((r) => r.state !== 'removed')
|
|
61
|
+
.map((r) => ({ id: r.id, description: r.description }));
|
|
62
|
+
},
|
|
63
|
+
async createRateLimitRule(zoneId, rule) {
|
|
64
|
+
await adoptLive(zoneId, rule.description);
|
|
65
|
+
const create = () => writeSlot(() => retrying(() => client.create({ zoneId, ...rule })));
|
|
66
|
+
try {
|
|
67
|
+
return { id: (await create()).id };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!hasReason(error, 'exists'))
|
|
71
|
+
throw error;
|
|
72
|
+
// The rule went live between the adopt and the create: adopt it and try once more.
|
|
73
|
+
adopted.delete(`${zoneId}/${rule.description}`);
|
|
74
|
+
await adoptLive(zoneId, rule.description);
|
|
75
|
+
return { id: (await create()).id };
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { TunnelIngressRule, createEdgeClient } from '@wtfalch/edge';
|
|
2
|
+
import { type WriteSlot } from './edge-dns.js';
|
|
3
|
+
/** The edge SDK's platform tunnel client: `createEdgeClient({ ... }).platform.tunnels`. */
|
|
4
|
+
export type EdgePlatformTunnels = ReturnType<typeof createEdgeClient>['platform']['tunnels'];
|
|
5
|
+
/** One ingress rule. `path` tells rules on one hostname apart; the catch-all has none. */
|
|
6
|
+
export type TunnelIngress = TunnelIngressRule;
|
|
7
|
+
export interface TunnelSummary {
|
|
8
|
+
/** Cloudflare's tunnel id: what every edge tunnel call takes. */
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Foundry's tunnel calls. Every tunnel call in foundry goes through this; the
|
|
14
|
+
* one implementation is {@link createEdgeTunnels}, which speaks to the edge
|
|
15
|
+
* service with the platform credential.
|
|
16
|
+
*/
|
|
17
|
+
export interface TunnelPort {
|
|
18
|
+
/** The tunnel named `name` that foundry may use (the platform's own, or one nobody adopted yet). */
|
|
19
|
+
findTunnelByName(name: string): Promise<TunnelSummary | undefined>;
|
|
20
|
+
createTunnel(name: string): Promise<TunnelSummary>;
|
|
21
|
+
/** Replaces the whole ingress; the last rule is the catch-all and has no hostname. */
|
|
22
|
+
putTunnelConfig(tunnelId: string, config: {
|
|
23
|
+
ingress: TunnelIngress[];
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
/** The connector token: a live credential. Hand it to the connector and nowhere else. */
|
|
26
|
+
getTunnelToken(tunnelId: string): Promise<string>;
|
|
27
|
+
deleteTunnel(tunnelId: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Edge answers a config, token or delete call for a live tunnel with no edge
|
|
31
|
+
* row `not_found` ("create or adopt it first"). So the adapter adopts a tunnel
|
|
32
|
+
* the moment it is about to be configured, read the token of or deleted: a
|
|
33
|
+
* tunnel it found by name (or that a create refused with `exists`) and whose
|
|
34
|
+
* owner the list reported as `null` is remembered, and adopted just before the
|
|
35
|
+
* first such call. A tunnel foundry recorded in an earlier apply and now names
|
|
36
|
+
* by id only is looked up on `not_found`, adopted if unowned, and the call
|
|
37
|
+
* retried once. Adopt is idempotent and the list reports the owner afterwards,
|
|
38
|
+
* so nothing is recorded in the plan and a later apply adopts nothing. A
|
|
39
|
+
* name claim's tunnel (`organisation`) is never touched. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createEdgeTunnels(client: EdgePlatformTunnels, options?: {
|
|
42
|
+
sleep?: (ms: number) => Promise<void>;
|
|
43
|
+
writeSlot?: WriteSlot;
|
|
44
|
+
}): TunnelPort;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
2
|
+
import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
|
|
3
|
+
const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
|
|
4
|
+
/** Edge's 409 for a live tunnel with the name and no edge row. */
|
|
5
|
+
const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && error.reason === 'exists';
|
|
6
|
+
/**
|
|
7
|
+
* Edge answers a config, token or delete call for a live tunnel with no edge
|
|
8
|
+
* row `not_found` ("create or adopt it first"). So the adapter adopts a tunnel
|
|
9
|
+
* the moment it is about to be configured, read the token of or deleted: a
|
|
10
|
+
* tunnel it found by name (or that a create refused with `exists`) and whose
|
|
11
|
+
* owner the list reported as `null` is remembered, and adopted just before the
|
|
12
|
+
* first such call. A tunnel foundry recorded in an earlier apply and now names
|
|
13
|
+
* by id only is looked up on `not_found`, adopted if unowned, and the call
|
|
14
|
+
* retried once. Adopt is idempotent and the list reports the owner afterwards,
|
|
15
|
+
* so nothing is recorded in the plan and a later apply adopts nothing. A
|
|
16
|
+
* name claim's tunnel (`organisation`) is never touched. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
|
|
17
|
+
*/
|
|
18
|
+
export function createEdgeTunnels(client, options = {}) {
|
|
19
|
+
const retrying = rateLimitRetrier(options.sleep);
|
|
20
|
+
const tunnels = {
|
|
21
|
+
list: (filter) => retrying(() => client.list(filter)),
|
|
22
|
+
create: (input) => retrying(() => client.create(input)),
|
|
23
|
+
putConfig: (id, input) => retrying(() => client.putConfig(id, input)),
|
|
24
|
+
token: (id) => retrying(() => client.token(id)),
|
|
25
|
+
remove: (id) => retrying(() => client.remove(id)),
|
|
26
|
+
adopt: (input) => retrying(() => client.adopt(input)),
|
|
27
|
+
};
|
|
28
|
+
const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
29
|
+
/** tunnel id -> a seen live tunnel with no edge row yet. */
|
|
30
|
+
const unadopted = new Map();
|
|
31
|
+
const remember = (views) => {
|
|
32
|
+
for (const view of views) {
|
|
33
|
+
if (view.owner === null)
|
|
34
|
+
unadopted.set(view.id, view);
|
|
35
|
+
else
|
|
36
|
+
unadopted.delete(view.id);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const adopt = async (view) => {
|
|
40
|
+
const result = await writeSlot(() => tunnels.adopt({ tunnels: [{ name: view.name, id: view.id }] }));
|
|
41
|
+
const skipped = result.skipped[0];
|
|
42
|
+
if (skipped) {
|
|
43
|
+
throw new Error(skipped.reason === 'organisation'
|
|
44
|
+
? `edge tunnels: ${view.name} is a company's own tunnel; foundry will not change it`
|
|
45
|
+
: `edge tunnels: ${view.name} cannot be adopted (${skipped.reason}); remove or fix it by hand`);
|
|
46
|
+
}
|
|
47
|
+
if (result.notFound.length > 0) {
|
|
48
|
+
throw new EdgeApiError(404, 'not_found', `edge tunnels: ${view.name} is no longer live`);
|
|
49
|
+
}
|
|
50
|
+
unadopted.delete(view.id);
|
|
51
|
+
};
|
|
52
|
+
/** Runs a call on a tunnel; adopts it first when seen unowned, and once more on `not_found`. */
|
|
53
|
+
const onTunnel = async (tunnelId, call) => {
|
|
54
|
+
const seen = unadopted.get(tunnelId);
|
|
55
|
+
if (seen)
|
|
56
|
+
await adopt(seen);
|
|
57
|
+
try {
|
|
58
|
+
return await call();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (!isNotFound(error))
|
|
62
|
+
throw error;
|
|
63
|
+
const live = (await tunnels.list()).tunnels;
|
|
64
|
+
remember(live);
|
|
65
|
+
const view = live.find((t) => t.id === tunnelId);
|
|
66
|
+
if (!view || view.owner !== null)
|
|
67
|
+
throw error;
|
|
68
|
+
await adopt(view);
|
|
69
|
+
return call();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const usable = (views) => views.find((v) => v.owner === 'platform') ?? views.find((v) => v.owner === null);
|
|
73
|
+
const findTunnelByName = async (name) => {
|
|
74
|
+
const { tunnels: found } = await tunnels.list({ name });
|
|
75
|
+
remember(found);
|
|
76
|
+
const view = usable(found.filter((v) => v.name === name && v.state !== 'removed'));
|
|
77
|
+
return view ? { id: view.id, name: view.name } : undefined;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
findTunnelByName,
|
|
81
|
+
async createTunnel(name) {
|
|
82
|
+
try {
|
|
83
|
+
const created = await writeSlot(() => tunnels.create({ name }));
|
|
84
|
+
return { id: created.id, name: created.name };
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// A live tunnel of this name exists with no edge row (made before edge
|
|
88
|
+
// owned tunnels): use it, adopting it before its first config call.
|
|
89
|
+
if (!isExists(error))
|
|
90
|
+
throw error;
|
|
91
|
+
const found = await findTunnelByName(name);
|
|
92
|
+
if (!found)
|
|
93
|
+
throw error;
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
async putTunnelConfig(tunnelId, config) {
|
|
98
|
+
await onTunnel(tunnelId, () => writeSlot(() => tunnels.putConfig(tunnelId, config)));
|
|
99
|
+
},
|
|
100
|
+
async getTunnelToken(tunnelId) {
|
|
101
|
+
const { token } = await onTunnel(tunnelId, () => tunnels.token(tunnelId));
|
|
102
|
+
return token;
|
|
103
|
+
},
|
|
104
|
+
async deleteTunnel(tunnelId) {
|
|
105
|
+
await onTunnel(tunnelId, () => writeSlot(() => tunnels.remove(tunnelId)));
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { createEdgeClient } from '@wtfalch/edge';
|
|
2
|
+
import { type WriteSlot } from './edge-dns.js';
|
|
3
|
+
import type { WorkersPort } from './executor.js';
|
|
4
|
+
/** The edge SDK's platform Workers client: `createEdgeClient({ ... }).platform.workers`. */
|
|
5
|
+
export type EdgePlatformWorkers = ReturnType<typeof createEdgeClient>['platform']['workers'];
|
|
6
|
+
/** Edge answers a first upload of a name that another upload is making, or a delete in flight, `conflict` + `busy`. */
|
|
7
|
+
export declare const EDGE_WORKERS_BUSY_ATTEMPTS = 5;
|
|
8
|
+
/** What the host supplies: fetches the artifact and returns its bytes, verified against `sha256`. Foundry never fetches. */
|
|
9
|
+
export interface EdgeWorkersOptions {
|
|
10
|
+
fetchArtifact: (url: string, sha256: string) => Promise<Uint8Array>;
|
|
11
|
+
sleep?: (ms: number) => Promise<void>;
|
|
12
|
+
/** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the other edge adapters: edge counts them all. */
|
|
13
|
+
writeSlot?: WriteSlot;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* {@link WorkersPort} over edge's `platform.workers`. The host fetches the
|
|
17
|
+
* artifact (`fetchArtifact`); this sends its base64 bytes and the sha256 to
|
|
18
|
+
* edge, which checks the digest again. The bytes are never kept, logged or
|
|
19
|
+
* put into an error here.
|
|
20
|
+
*
|
|
21
|
+
* Edge refuses to touch a live script or route that has no edge row (an upload
|
|
22
|
+
* of a live name answers `exists`, a delete answers `not_found`). So the
|
|
23
|
+
* adapter adopts one the moment it is about to be uploaded over or deleted:
|
|
24
|
+
* it lists the name (or the zone's routes), and adopts only a view whose
|
|
25
|
+
* `owner` is `null`. Adopt is idempotent and the list reports the owner
|
|
26
|
+
* afterwards, so nothing is recorded in the plan. A `skipped` route is an
|
|
27
|
+
* error. `scriptExists` and `findRoute` only read: a foreign script or route
|
|
28
|
+
* foundry refuses to overwrite is never adopted. A 404 on a delete means gone.
|
|
29
|
+
* A `rate_limited` or `busy` answer is retried.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createEdgeWorkers(client: EdgePlatformWorkers, options: EdgeWorkersOptions): WorkersPort;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
2
|
+
import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
|
|
3
|
+
/** Edge answers a first upload of a name that another upload is making, or a delete in flight, `conflict` + `busy`. */
|
|
4
|
+
export const EDGE_WORKERS_BUSY_ATTEMPTS = 5;
|
|
5
|
+
const BUSY_FIRST_DELAY_MS = 1000;
|
|
6
|
+
const BUSY_MAX_DELAY_MS = 8000;
|
|
7
|
+
const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
|
|
8
|
+
const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && String(error.reason) === 'exists';
|
|
9
|
+
const isBusy = (error) => error instanceof EdgeApiError && error.code === 'conflict' && String(error.reason) === 'busy';
|
|
10
|
+
/**
|
|
11
|
+
* Retries a call edge answered `conflict` + `busy`, waiting 1 s, 2 s, 4 s, then
|
|
12
|
+
* 8 s (the cap), up to {@link EDGE_WORKERS_BUSY_ATTEMPTS} tries.
|
|
13
|
+
*/
|
|
14
|
+
function busyRetrier(sleepOption) {
|
|
15
|
+
const sleep = sleepOption ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
16
|
+
return async (task) => {
|
|
17
|
+
for (let attempt = 1;; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
return await task();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (!isBusy(error) || attempt >= EDGE_WORKERS_BUSY_ATTEMPTS)
|
|
23
|
+
throw error;
|
|
24
|
+
await sleep(Math.min(BUSY_FIRST_DELAY_MS * 2 ** (attempt - 1), BUSY_MAX_DELAY_MS));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* {@link WorkersPort} over edge's `platform.workers`. The host fetches the
|
|
31
|
+
* artifact (`fetchArtifact`); this sends its base64 bytes and the sha256 to
|
|
32
|
+
* edge, which checks the digest again. The bytes are never kept, logged or
|
|
33
|
+
* put into an error here.
|
|
34
|
+
*
|
|
35
|
+
* Edge refuses to touch a live script or route that has no edge row (an upload
|
|
36
|
+
* of a live name answers `exists`, a delete answers `not_found`). So the
|
|
37
|
+
* adapter adopts one the moment it is about to be uploaded over or deleted:
|
|
38
|
+
* it lists the name (or the zone's routes), and adopts only a view whose
|
|
39
|
+
* `owner` is `null`. Adopt is idempotent and the list reports the owner
|
|
40
|
+
* afterwards, so nothing is recorded in the plan. A `skipped` route is an
|
|
41
|
+
* error. `scriptExists` and `findRoute` only read: a foreign script or route
|
|
42
|
+
* foundry refuses to overwrite is never adopted. A 404 on a delete means gone.
|
|
43
|
+
* A `rate_limited` or `busy` answer is retried.
|
|
44
|
+
*/
|
|
45
|
+
export function createEdgeWorkers(client, options) {
|
|
46
|
+
const rateRetry = rateLimitRetrier(options.sleep);
|
|
47
|
+
const busyRetry = busyRetrier(options.sleep);
|
|
48
|
+
const retrying = (task) => busyRetry(() => rateRetry(task));
|
|
49
|
+
const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
50
|
+
/** Script names and `zoneId/routeId` keys already adopted (or created) through this adapter. */
|
|
51
|
+
const adoptedScripts = new Set();
|
|
52
|
+
const adoptedRoutes = new Set();
|
|
53
|
+
const liveScript = async (name) => {
|
|
54
|
+
const { scripts } = await retrying(() => client.listScripts({ name }));
|
|
55
|
+
return scripts.find((s) => s.name === name && s.state !== 'removed');
|
|
56
|
+
};
|
|
57
|
+
const liveRoute = async (zoneId, pattern) => {
|
|
58
|
+
const { routes } = await retrying(() => client.listRoutes({ zoneId, pattern }));
|
|
59
|
+
return routes.find((r) => r.pattern === pattern && r.state !== 'removed');
|
|
60
|
+
};
|
|
61
|
+
/** Adopts `name` when its live script has no edge row. Returns false when no live script exists. */
|
|
62
|
+
const adoptScript = async (name) => {
|
|
63
|
+
if (adoptedScripts.has(name))
|
|
64
|
+
return true;
|
|
65
|
+
const view = await liveScript(name);
|
|
66
|
+
if (!view)
|
|
67
|
+
return false;
|
|
68
|
+
if (view.owner === null) {
|
|
69
|
+
const { scripts } = await writeSlot(() => retrying(() => client.adopt({ scripts: [{ name }] })));
|
|
70
|
+
if (scripts.notFound.length > 0)
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
adoptedScripts.add(name);
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
/** Adopts the route `id` in `zoneId` when it has no edge row. Returns false when it is gone. */
|
|
77
|
+
const adoptRoute = async (zoneId, id) => {
|
|
78
|
+
const key = `${zoneId}/${id}`;
|
|
79
|
+
if (adoptedRoutes.has(key))
|
|
80
|
+
return true;
|
|
81
|
+
const { routes: live } = await retrying(() => client.listRoutes({ zoneId }));
|
|
82
|
+
const view = live.find((r) => r.id === id && r.state !== 'removed');
|
|
83
|
+
if (!view)
|
|
84
|
+
return false;
|
|
85
|
+
if (view.owner === null) {
|
|
86
|
+
const { routes } = await writeSlot(() => retrying(() => client.adopt({ routes: [{ zoneId, pattern: view.pattern, id: view.id }] })));
|
|
87
|
+
const skipped = routes.skipped[0];
|
|
88
|
+
if (skipped) {
|
|
89
|
+
throw new Error(`edge workers: route ${view.pattern} in zone ${zoneId} cannot be adopted (${skipped.reason}); remove or fix it by hand`);
|
|
90
|
+
}
|
|
91
|
+
if (routes.notFound.length > 0)
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
adoptedRoutes.add(key);
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
async scriptExists(name) {
|
|
99
|
+
return (await liveScript(name)) !== undefined;
|
|
100
|
+
},
|
|
101
|
+
async uploadScript(input) {
|
|
102
|
+
await adoptScript(input.name);
|
|
103
|
+
const bytes = await options.fetchArtifact(input.artifactUrl, input.artifactSha256);
|
|
104
|
+
await writeSlot(() => retrying(() => client.uploadScript(input.name, {
|
|
105
|
+
script: Buffer.from(bytes).toString('base64'),
|
|
106
|
+
artifactSha256: input.artifactSha256,
|
|
107
|
+
compatibilityDate: input.compatibilityDate,
|
|
108
|
+
...(input.bindings ? { bindings: input.bindings } : {}),
|
|
109
|
+
})));
|
|
110
|
+
adoptedScripts.add(input.name);
|
|
111
|
+
},
|
|
112
|
+
async findRoute(zoneId, pattern) {
|
|
113
|
+
const view = await liveRoute(zoneId, pattern);
|
|
114
|
+
return view?.script ? { id: view.id, script: view.script } : undefined;
|
|
115
|
+
},
|
|
116
|
+
async createRoute(zoneId, pattern, script) {
|
|
117
|
+
const create = () => writeSlot(() => retrying(() => client.createRoute({ zoneId, pattern, ...(script ? { script } : {}) })));
|
|
118
|
+
let created;
|
|
119
|
+
try {
|
|
120
|
+
created = await create();
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (!isExists(error))
|
|
124
|
+
throw error;
|
|
125
|
+
// A live route on the pattern with no edge row (made before edge owned
|
|
126
|
+
// Workers): adopt it when it is the one route there and runs the same
|
|
127
|
+
// script (or none). Anything else is not foundry's to take.
|
|
128
|
+
const { routes } = await retrying(() => client.listRoutes({ zoneId, pattern }));
|
|
129
|
+
const live = routes.filter((r) => r.pattern === pattern && r.state !== 'removed');
|
|
130
|
+
const only = live.length === 1 ? live[0] : undefined;
|
|
131
|
+
if (!only || only.owner !== null || (only.script ?? null) !== (script ?? null))
|
|
132
|
+
throw error;
|
|
133
|
+
if (!(await adoptRoute(zoneId, only.id)))
|
|
134
|
+
throw error;
|
|
135
|
+
created = { id: only.id };
|
|
136
|
+
}
|
|
137
|
+
adoptedRoutes.add(`${zoneId}/${created.id}`);
|
|
138
|
+
return { id: created.id };
|
|
139
|
+
},
|
|
140
|
+
async deleteRoute(zoneId, routeId) {
|
|
141
|
+
if (!(await adoptRoute(zoneId, routeId)))
|
|
142
|
+
return;
|
|
143
|
+
try {
|
|
144
|
+
await writeSlot(() => retrying(() => client.removeRoute(zoneId, routeId)));
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (!isNotFound(error))
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
adoptedRoutes.delete(`${zoneId}/${routeId}`);
|
|
151
|
+
},
|
|
152
|
+
async deleteScript(name) {
|
|
153
|
+
if (!(await adoptScript(name)))
|
|
154
|
+
return;
|
|
155
|
+
try {
|
|
156
|
+
await writeSlot(() => retrying(() => client.removeScript(name)));
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!isNotFound(error))
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
adoptedScripts.delete(name);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { createEdgeClient } from '@wtfalch/edge';
|
|
2
|
+
import { type WriteSlot } from './edge-dns.js';
|
|
3
|
+
/** The edge SDK's platform zones client: `createEdgeClient({ ... }).platform.zones`. */
|
|
4
|
+
export type EdgePlatformZones = ReturnType<typeof createEdgeClient>['platform']['zones'];
|
|
5
|
+
/**
|
|
6
|
+
* Foundry's Cloudflare zone calls. Both go through this; the one implementation
|
|
7
|
+
* is {@link createEdgeZones}, which speaks to the edge service with the platform credential.
|
|
8
|
+
*/
|
|
9
|
+
export interface ZonePort {
|
|
10
|
+
/** The zone that serves `domain`: the longest suffix of it that is a live zone, or `undefined`. */
|
|
11
|
+
findZoneByApex(domain: string): Promise<{
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
} | undefined>;
|
|
15
|
+
/** Makes the zone, or returns the one already made for that name. */
|
|
16
|
+
createZone(name: string): Promise<{
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export interface EdgeZonesOptions {
|
|
22
|
+
sleep?: (ms: number) => Promise<void>;
|
|
23
|
+
/** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the other edge adapters: edge counts them all. */
|
|
24
|
+
writeSlot?: WriteSlot;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* {@link ZonePort} over edge's `platform.zones`.
|
|
28
|
+
*
|
|
29
|
+
* Edge lists a zone by its exact name, so a lookup tries the full name and
|
|
30
|
+
* then each parent down to two labels, and never writes. Edge answers a create
|
|
31
|
+
* of a live zone that has no edge row `exists`. So the adapter adopts that zone
|
|
32
|
+
* the moment it is about to create it: it lists the name, and adopts a view
|
|
33
|
+
* whose `owner` is `null`. Adopt is idempotent and the list reports the owner
|
|
34
|
+
* afterwards, so nothing is recorded in the plan. A zone edge reports as
|
|
35
|
+
* `taken` is an error. A `rate_limited` or `busy` answer is retried.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createEdgeZones(client: EdgePlatformZones, options?: EdgeZonesOptions): ZonePort;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
2
|
+
import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
|
|
3
|
+
import { busyRetrier, hasReason } from './edge-ratelimits.js';
|
|
4
|
+
/**
|
|
5
|
+
* {@link ZonePort} over edge's `platform.zones`.
|
|
6
|
+
*
|
|
7
|
+
* Edge lists a zone by its exact name, so a lookup tries the full name and
|
|
8
|
+
* then each parent down to two labels, and never writes. Edge answers a create
|
|
9
|
+
* of a live zone that has no edge row `exists`. So the adapter adopts that zone
|
|
10
|
+
* the moment it is about to create it: it lists the name, and adopts a view
|
|
11
|
+
* whose `owner` is `null`. Adopt is idempotent and the list reports the owner
|
|
12
|
+
* afterwards, so nothing is recorded in the plan. A zone edge reports as
|
|
13
|
+
* `taken` is an error. A `rate_limited` or `busy` answer is retried.
|
|
14
|
+
*/
|
|
15
|
+
export function createEdgeZones(client, options = {}) {
|
|
16
|
+
const rateRetry = rateLimitRetrier(options.sleep);
|
|
17
|
+
const busyRetry = busyRetrier(options.sleep);
|
|
18
|
+
const retrying = (task) => busyRetry(() => rateRetry(task));
|
|
19
|
+
const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
20
|
+
const adoptLive = async (name) => {
|
|
21
|
+
const { zones } = await retrying(() => client.list({ name }));
|
|
22
|
+
const view = zones.find((z) => z.name === name);
|
|
23
|
+
if (!view || view.owner !== null)
|
|
24
|
+
return;
|
|
25
|
+
const result = await writeSlot(() => retrying(() => client.adopt({ zones: [{ name }] })));
|
|
26
|
+
if (result.skipped.length > 0) {
|
|
27
|
+
throw new Error(`edge zones: zone ${name} cannot be adopted (${result.skipped[0]?.reason}); remove or fix it by hand`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const create = (name) => writeSlot(() => retrying(() => client.create({ name }))).catch((error) => {
|
|
31
|
+
if (error instanceof EdgeApiError && error.code === 'not_configured') {
|
|
32
|
+
throw new Error(`edge zones: edge's Cloudflare token cannot create zones yet, so zone ${name} was not created; give it the Zone Edit permission or create the zone by hand`);
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
async findZoneByApex(domain) {
|
|
38
|
+
const labels = domain.toLowerCase().split('.');
|
|
39
|
+
for (let i = 0; labels.length - i >= 2; i++) {
|
|
40
|
+
const name = labels.slice(i).join('.');
|
|
41
|
+
const { zones } = await retrying(() => client.list({ name }));
|
|
42
|
+
const view = zones.find((z) => z.name === name);
|
|
43
|
+
if (view)
|
|
44
|
+
return { id: view.id, name: view.name };
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
},
|
|
48
|
+
async createZone(name) {
|
|
49
|
+
await adoptLive(name);
|
|
50
|
+
try {
|
|
51
|
+
const made = await create(name);
|
|
52
|
+
return { id: made.id, name: made.name };
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (!hasReason(error, 'exists'))
|
|
56
|
+
throw error;
|
|
57
|
+
// The zone went live between the adopt and the create: adopt it and try once more.
|
|
58
|
+
await adoptLive(name);
|
|
59
|
+
const made = await create(name);
|
|
60
|
+
return { id: made.id, name: made.name };
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -35,6 +35,16 @@ export declare const entryEdgeMintedAtScopeKey: (entryKey: string) => string;
|
|
|
35
35
|
export declare const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
|
|
36
36
|
/** The reserved labels last sent to the edge service, as that sorted list's own JSON. */
|
|
37
37
|
export declare const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = "edge_reserved_labels_applied";
|
|
38
|
+
/**
|
|
39
|
+
* F3: the org's own Boule holds an edge key too, so a company edits its DNS
|
|
40
|
+
* records there (`records:read`, `records:edit`: edge's entry keys carry both).
|
|
41
|
+
* It rides the hosting-entry key path under this entry key. The leading
|
|
42
|
+
* underscore is one no hosting entry key can have (`ENTRY_KEY` in `org.ts`),
|
|
43
|
+
* so it never collides with a declared entry.
|
|
44
|
+
*/
|
|
45
|
+
export declare const BOULE_EDGE_ENTRY_KEY = "_boule";
|
|
46
|
+
/** The recorded-id key of the Coolify app an edge key's env is written to. */
|
|
47
|
+
export declare function edgeKeyAppRecordKey(org: DeclaredOrg, entryKey: string): string;
|
|
38
48
|
export declare const entryEdgeKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
39
49
|
/**
|
|
40
50
|
* Every label under `org.domain` that foundry provisions or the platform
|