@wtfalch/foundry 0.42.1 → 0.43.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.
Files changed (50) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +5 -1
  3. package/dist/index.js +3 -1
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/org.d.ts +13 -0
  7. package/dist/org.js +38 -1
  8. package/dist/plan/archon-blue-green.d.ts +54 -0
  9. package/dist/plan/archon-blue-green.js +72 -0
  10. package/dist/plan/blue-green-apply.d.ts +82 -0
  11. package/dist/plan/blue-green-apply.js +360 -0
  12. package/dist/plan/blue-green.d.ts +223 -0
  13. package/dist/plan/blue-green.js +444 -0
  14. package/dist/plan/cloudflared-connector.d.ts +26 -0
  15. package/dist/plan/cloudflared-connector.js +37 -0
  16. package/dist/plan/diff.d.ts +6 -0
  17. package/dist/plan/diff.js +31 -2
  18. package/dist/plan/executor.d.ts +34 -0
  19. package/dist/plan/executor.js +244 -9
  20. package/dist/plan/plan-apply.d.ts +51 -0
  21. package/dist/plan/plan-apply.js +222 -0
  22. package/dist/plan/platform-diff.d.ts +9 -0
  23. package/dist/plan/platform-diff.js +24 -1
  24. package/dist/plan/platform-providers.d.ts +15 -1
  25. package/dist/plan/platform-providers.js +90 -5
  26. package/dist/plan/proxy-labels.d.ts +2 -2
  27. package/dist/plan/proxy-labels.js +12 -4
  28. package/dist/plan/release-host.d.ts +59 -0
  29. package/dist/plan/release-host.js +239 -0
  30. package/dist/plan/remote-database.d.ts +124 -0
  31. package/dist/plan/remote-database.js +373 -0
  32. package/dist/platform.d.ts +11 -1
  33. package/dist/platform.js +4 -1
  34. package/dist/release/decide.d.ts +95 -0
  35. package/dist/release/decide.js +242 -0
  36. package/dist/release/health.d.ts +48 -0
  37. package/dist/release/health.js +61 -0
  38. package/dist/release/run.d.ts +40 -0
  39. package/dist/release/run.js +56 -0
  40. package/dist/release/state.d.ts +64 -0
  41. package/dist/release/state.js +126 -0
  42. package/dist/release/types.d.ts +54 -0
  43. package/dist/release/types.js +4 -0
  44. package/dist/store/postgres.d.ts +8 -0
  45. package/dist/store/postgres.js +56 -3
  46. package/dist/store/schema.d.ts +154 -0
  47. package/dist/store/schema.js +12 -0
  48. package/package.json +1 -1
  49. package/versions.digests.json +2 -1
  50. package/versions.json +2 -1
@@ -0,0 +1,373 @@
1
+ import { pinnedRef } from '../bootstrap/versions.js';
2
+ import { PRIMARY_SERVER_SCOPE_KEY, splitServers } from './blue-green.js';
3
+ import { isNotFoundError } from './executor.js';
4
+ import { appNameFor } from './platform-diff.js';
5
+ /**
6
+ * A blue/green app on a server other than the database's reaches its one database through a
7
+ * Cloudflare tunnel TCP route behind an Access service token (William, 2026-10-15). The tunnel has a
8
+ * `tcp://<org>-db:5432` rule on an internal hostname; Access lets only the service token in; a
9
+ * `cloudflared access tcp` sidecar on each other server listens on 5432 under the name
10
+ * {@link remoteDatabaseHost}.
11
+ */
12
+ export const REMOTE_DATABASE_PORT = 5432;
13
+ const DELIVERY_ATTEMPTS = 3;
14
+ /** The sidecar's name on the Docker network of `serverUuid`, or `null` on the database's own server. */
15
+ export function remoteDatabaseHost(entryKey, serverUuid, primaryUuid) {
16
+ return serverUuid === primaryUuid ? null : `${entryKey}-db-access`;
17
+ }
18
+ /** The `host:port` a color on a server other than the database's connects to. */
19
+ export const remoteDatabaseAddress = (host) => `${host}:${REMOTE_DATABASE_PORT}`;
20
+ /**
21
+ * The facts for {@link planRemoteDatabase}, from what the planner already read. The zone is the one the org's
22
+ * tunnel names live in (`org.domain`); `access` says the host can reach Access, tunnels, DNS and zones.
23
+ */
24
+ export function remoteDatabaseFactsFrom(input) {
25
+ const { up } = splitServers(input.servers);
26
+ return {
27
+ serverUuids: up,
28
+ primaryUuid: input.recorded[PRIMARY_SERVER_SCOPE_KEY] ?? up[0] ?? null,
29
+ zone: input.org.domain ?? null,
30
+ access: input.access,
31
+ };
32
+ }
33
+ /** The scope ids one entry's step keeps. None holds a secret. */
34
+ export const remoteDatabaseKeys = (entryKey) => ({
35
+ /** The service token's id, from its mint until its secret reached every sidecar. No `:`. */
36
+ undelivered: `bgdb.${entryKey}.undelivered`,
37
+ /** The service token's id once delivered. */
38
+ token: `bgdb.${entryKey}.token`,
39
+ access: `bgdb.${entryKey}.access`,
40
+ route: `bgdb.${entryKey}.route`,
41
+ applied: `bgdb.${entryKey}.applied`,
42
+ sidecar: (serverUuid) => `bgdb.${entryKey}.sidecar.${serverUuid}`,
43
+ });
44
+ /** The tunnel's HTTP rules as the org tunnel step last wrote them (JSON), so a later write keeps them. */
45
+ export const TUNNEL_HTTP_INGRESS_SCOPE_KEY = 'cloudflare_tunnel_http_ingress';
46
+ const ROUTE_KEY = /^bgdb\.[^.:]+\.route$/;
47
+ /** Every TCP route recorded for the org, in key order. */
48
+ export function recordedTcpRoutes(recorded) {
49
+ const routes = [];
50
+ for (const key of Object.keys(recorded).sort()) {
51
+ if (!ROUTE_KEY.test(key) || !recorded[key])
52
+ continue;
53
+ try {
54
+ const parsed = JSON.parse(recorded[key]);
55
+ if (typeof parsed.hostname === 'string' && typeof parsed.service === 'string') {
56
+ routes.push({ hostname: parsed.hostname, service: parsed.service });
57
+ }
58
+ }
59
+ catch {
60
+ // A record that is not JSON is ignored; the next apply of the step writes it again.
61
+ }
62
+ }
63
+ return routes;
64
+ }
65
+ /** The HTTP rules recorded by the tunnel step, catch-all included; `[]` if none or unreadable. */
66
+ export function recordedHttpIngress(recorded) {
67
+ try {
68
+ const parsed = JSON.parse(recorded[TUNNEL_HTTP_INGRESS_SCOPE_KEY] ?? '[]');
69
+ return Array.isArray(parsed) ? parsed : [];
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ }
75
+ /**
76
+ * The tunnel's whole ingress: the TCP rules first (their names are exact), then every HTTP rule, the
77
+ * catch-all last. `putTunnelConfig` replaces the whole list, so every writer builds it through this.
78
+ */
79
+ export function mergeTcpIngress(http, tcp) {
80
+ const names = new Set(tcp.map((r) => r.hostname));
81
+ const rules = http.filter((r) => !(r.hostname && names.has(r.hostname)));
82
+ const catchAll = rules.filter((r) => !r.hostname);
83
+ return [
84
+ ...tcp.map((r) => ({ hostname: r.hostname, service: r.service })),
85
+ ...rules.filter((r) => r.hostname),
86
+ ...(catchAll.length > 0 ? catchAll : [{ service: 'http_status:404' }]),
87
+ ];
88
+ }
89
+ /** The entry that gets a remote route: blue/green with the org's database. */
90
+ function routedEntry(org) {
91
+ if (!org.hosting.enabled)
92
+ return undefined;
93
+ return org.hosting.entries.find((e) => e.database && e.blueGreen);
94
+ }
95
+ const appliedMarker = (d) => JSON.stringify({ hostname: d.hostname, database: d.database, servers: d.servers });
96
+ /** Whether the route step has been applied for `serverUuid`: its marker names the server. */
97
+ export function remoteDatabaseAppliedFor(recorded, entryKey, serverUuid) {
98
+ try {
99
+ const marker = JSON.parse(recorded[remoteDatabaseKeys(entryKey).applied] ?? 'null');
100
+ return Array.isArray(marker?.servers) && marker.servers.includes(serverUuid);
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ /**
107
+ * Plans the remote database route for the org's blue/green database entry. One server, or no facts,
108
+ * plans nothing. A missing zone, port, tunnel or primary is a blocked line (rule 2), and so is a token
109
+ * that was minted and never delivered (rule 1): it is never minted again.
110
+ */
111
+ export function planRemoteDatabase(org, facts, recorded) {
112
+ const entry = routedEntry(org);
113
+ if (!entry || !facts || facts.serverUuids.length < 2)
114
+ return { step: null, blocked: null };
115
+ const key = `cloudflare:db-route:${org.id}`;
116
+ const block = (reason) => ({
117
+ step: null,
118
+ blocked: {
119
+ description: `${org.slug}'s remote database route for ${entry.key} is blocked, not planned: ${reason}`,
120
+ key,
121
+ },
122
+ });
123
+ const keys = remoteDatabaseKeys(entry.key);
124
+ const lost = recorded[keys.undelivered];
125
+ if (lost) {
126
+ return block(`its Access service token ${lost} was minted and its secret never reached every sidecar; delete the token in Cloudflare and clear the recorded id before minting again`);
127
+ }
128
+ if (!facts.access)
129
+ return block('the host supplies no Access, tunnel, DNS or zone port');
130
+ if (!facts.zone)
131
+ return block("the platform's edge has no internal zone");
132
+ if (!facts.primaryUuid)
133
+ return block('no primary server is recorded');
134
+ if (!recorded[`cloudflare:tunnel:${org.id}`]) {
135
+ return block(`${org.slug}'s Cloudflare tunnel is not applied yet`);
136
+ }
137
+ const database = recorded[`coolify:database:${org.id}`];
138
+ if (!database)
139
+ return block(`${org.slug}'s database is not applied yet`);
140
+ const app = appNameFor(org, entry);
141
+ const desired = {
142
+ entry: entry.key,
143
+ app,
144
+ hostname: `${app}-db.${facts.zone}`,
145
+ database,
146
+ primary: facts.primaryUuid,
147
+ servers: facts.serverUuids.filter((s) => s !== facts.primaryUuid).sort(),
148
+ zone: facts.zone,
149
+ };
150
+ // A server added after the token was minted gets its sidecar's secret copied from another sidecar;
151
+ // with no sidecar to copy from there is nothing to give it.
152
+ if (recorded[keys.token] && desired.servers.every((server) => !recorded[keys.sidecar(server)])) {
153
+ return block(`service token ${recorded[keys.token]} cannot be shown again and no sidecar holds it to copy; delete the token in Cloudflare and clear the recorded ids to mint it again`);
154
+ }
155
+ if (recorded[keys.applied] === appliedMarker(desired))
156
+ return { step: null, blocked: null };
157
+ return {
158
+ blocked: null,
159
+ step: {
160
+ action: {
161
+ system: 'cloudflare',
162
+ kind: 'db-route',
163
+ key: org.id,
164
+ desired: { ...desired },
165
+ },
166
+ step: {
167
+ description: `reach ${org.slug}'s database from ${desired.servers.length} other server(s) through a tunnel route behind an Access service token`,
168
+ destroys: false,
169
+ migrates: false,
170
+ },
171
+ },
172
+ };
173
+ }
174
+ /** The Dockerfile of the sidecar: the pinned `cloudflared`, listening on 5432 for the Access-protected route. */
175
+ export function remoteDatabaseDockerfile(hostname) {
176
+ return [
177
+ `FROM cloudflare/cloudflared:${pinnedRef('cloudflared')}`,
178
+ `CMD ["access", "tcp", "--hostname", "${hostname}", "--url", "0.0.0.0:${REMOTE_DATABASE_PORT}"]`,
179
+ // The image is distroless (no shell, no nc): the exec-form check proves only that the binary runs.
180
+ 'HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["cloudflared", "version"]',
181
+ '',
182
+ ].join('\n');
183
+ }
184
+ const TOKEN_ID_ENV = 'TUNNEL_SERVICE_TOKEN_ID';
185
+ const TOKEN_SECRET_ENV = 'TUNNEL_SERVICE_TOKEN_SECRET';
186
+ /**
187
+ * Applies one entry's route. The Access app and its service-token policy come first, so the tunnel route
188
+ * and the DNS record never exist without the gate in front of them. The service token is minted once
189
+ * (rule 1): its id is recorded and persisted before delivery, the secret goes only to the sidecars' env
190
+ * (rule 6), the delivered id replaces it, and the applied marker is the last write. A failure halfway
191
+ * leaves a repair for the next run: the Access app, sidecars, route and DNS are found again; a minted
192
+ * token is never minted again. A sidecar made after the mint takes its secret from an existing sidecar's
193
+ * env. Every outside call is bounded by the ports' own timeouts (edge SDK and Coolify client).
194
+ */
195
+ export async function applyRemoteDatabase(deps, run) {
196
+ const { desired, org } = run;
197
+ const keys = remoteDatabaseKeys(desired.entry);
198
+ const tunnelId = run.recorded()[`cloudflare:tunnel:${org.id}`];
199
+ if (!tunnelId)
200
+ throw new Error(`remote database for ${org.slug}: the Cloudflare tunnel is not applied`);
201
+ const zone = await deps.zones.findZoneByApex(desired.zone);
202
+ if (!zone)
203
+ throw new Error(`remote database for ${org.slug}: zone ${desired.zone} is not live`);
204
+ let accessAppId = run.recorded()[keys.access];
205
+ if (!accessAppId) {
206
+ const live = (await deps.access.listAccessApps()).find((a) => a.domain === desired.hostname);
207
+ accessAppId = (live ??
208
+ (await deps.access.createAccessApp({
209
+ name: `${desired.app}-db-access`,
210
+ domain: desired.hostname,
211
+ type: 'self_hosted',
212
+ }))).id;
213
+ run.record(keys.access, accessAppId);
214
+ await run.persist();
215
+ }
216
+ // Sidecars exist before the secret does, so the secret has somewhere to go the moment it is minted.
217
+ const { environment } = await deps.coolify.findOrCreateEnvironment(run.projectUuid, 'production');
218
+ const sidecars = [];
219
+ for (const server of desired.servers) {
220
+ const host = remoteDatabaseHost(desired.app, server, desired.primary);
221
+ if (!host)
222
+ continue;
223
+ const recordedId = run.recorded()[keys.sidecar(server)];
224
+ let appId = null;
225
+ if (recordedId) {
226
+ try {
227
+ await deps.coolify.getApplication(recordedId);
228
+ appId = recordedId;
229
+ }
230
+ catch (error) {
231
+ if (!isNotFoundError(error))
232
+ throw error;
233
+ }
234
+ }
235
+ let fresh = false;
236
+ if (!appId) {
237
+ const created = await deps.coolify.createDockerfileApplication({
238
+ project_uuid: run.projectUuid,
239
+ server_uuid: server,
240
+ environment_name: environment.name,
241
+ environment_uuid: environment.uuid,
242
+ dockerfile: remoteDatabaseDockerfile(desired.hostname),
243
+ name: host,
244
+ description: `cloudflared access sidecar: ${org.slug}'s database over the tunnel`,
245
+ instant_deploy: false,
246
+ autogenerate_domain: false,
247
+ });
248
+ appId = created.uuid;
249
+ fresh = true;
250
+ // The internal name is what `<app>-db-access` resolves to on the server's Docker network.
251
+ await deps.coolify.updateApplication(appId, {
252
+ custom_internal_name: host,
253
+ health_check_enabled: false,
254
+ });
255
+ run.record(keys.sidecar(server), appId);
256
+ await run.persist();
257
+ }
258
+ sidecars.push({ server, appId, fresh });
259
+ }
260
+ // The token: found by recorded id first. A token of the name that foundry has no id for is not
261
+ // minted over; the step fails with fixed text.
262
+ const lost = run.recorded()[keys.undelivered];
263
+ if (lost) {
264
+ throw new Error(`remote database for ${org.slug}: service token ${lost} was minted and not delivered; delete it in Cloudflare and clear the recorded id before minting again`);
265
+ }
266
+ let tokenId = run.recorded()[keys.token];
267
+ let secrets = null;
268
+ let deliverTo = sidecars;
269
+ if (!tokenId) {
270
+ const name = `${desired.app}-db-access`;
271
+ if (await deps.access.findServiceTokenByName(name)) {
272
+ throw new Error(`remote database for ${org.slug}: a service token named ${name} exists and foundry holds no id for it; delete it in Cloudflare before applying again`);
273
+ }
274
+ const minted = await deps.access.createServiceToken(name);
275
+ deps.secret(minted.clientSecret);
276
+ deps.secret(minted.clientId);
277
+ run.record(keys.undelivered, minted.id);
278
+ await run.persist();
279
+ tokenId = minted.id;
280
+ secrets = { clientId: minted.clientId, clientSecret: minted.clientSecret };
281
+ }
282
+ else {
283
+ // A sidecar made after the token was minted takes the secret from a sidecar that already holds it.
284
+ deliverTo = sidecars.filter((sidecar) => sidecar.fresh);
285
+ if (deliverTo.length > 0) {
286
+ secrets = await secretsOf(deps, sidecars.filter((sidecar) => !sidecar.fresh));
287
+ if (!secrets) {
288
+ throw new Error(`remote database for ${org.slug}: the sidecar on server ${deliverTo[0]?.server} is new and no existing sidecar holds service token ${tokenId} to copy; delete the token in Cloudflare and clear the recorded ids to mint it again`);
289
+ }
290
+ }
291
+ }
292
+ if (secrets) {
293
+ const failed = [];
294
+ for (const sidecar of deliverTo) {
295
+ if (!(await deliver(deps, sidecar.appId, secrets)))
296
+ failed.push(sidecar.server);
297
+ }
298
+ if (failed.length > 0) {
299
+ // Ids only: the cause of a failed write can echo the request body.
300
+ throw new Error(`remote database for ${org.slug}: service token ${tokenId} could not be delivered to the sidecar on server ${failed.join(', ')} after ${DELIVERY_ATTEMPTS} attempts; delete the token in Cloudflare and clear the recorded id before minting again`);
301
+ }
302
+ if (run.recorded()[keys.undelivered]) {
303
+ run.record(keys.token, tokenId);
304
+ run.record(keys.undelivered, '');
305
+ await run.persist();
306
+ }
307
+ }
308
+ const policies = await deps.access.listAccessPolicies(accessAppId);
309
+ const hasPolicy = policies.some((p) => p.decision === 'non_identity' &&
310
+ (p.include ?? []).some((r) => r.service_token?.token_id === tokenId));
311
+ if (!hasPolicy) {
312
+ await deps.access.createAccessPolicy(accessAppId, {
313
+ name: 'database sidecars',
314
+ decision: 'non_identity',
315
+ include: [{ service_token: { token_id: tokenId } }],
316
+ });
317
+ }
318
+ // The gate exists: now the route and the name that reach the database.
319
+ run.record(keys.route, JSON.stringify({
320
+ hostname: desired.hostname,
321
+ service: `tcp://${run.databaseHost}:${REMOTE_DATABASE_PORT}`,
322
+ }));
323
+ await deps.tunnels.putTunnelConfig(tunnelId, {
324
+ ingress: mergeTcpIngress(recordedHttpIngress(run.recorded()), recordedTcpRoutes(run.recorded())),
325
+ });
326
+ if ((await deps.dns.findDnsRecord(zone.name, desired.hostname, 'CNAME')).length === 0) {
327
+ await deps.dns.createDnsRecord(zone.name, {
328
+ type: 'CNAME',
329
+ name: desired.hostname,
330
+ content: `${tunnelId}.cfargotunnel.com`,
331
+ proxied: true,
332
+ });
333
+ }
334
+ for (const sidecar of sidecars) {
335
+ const live = await deps.coolify.getApplication(sidecar.appId);
336
+ const up = Boolean(live.status?.startsWith('running') || live.status?.startsWith('starting'));
337
+ if (secrets || sidecar.fresh || !up)
338
+ await deps.coolify.deploy(sidecar.appId);
339
+ }
340
+ run.record(keys.applied, appliedMarker(desired));
341
+ await run.persist();
342
+ }
343
+ /** The service token an existing sidecar already holds in its env, registered for redaction; `null` when none does. */
344
+ async function secretsOf(deps, sidecars) {
345
+ for (const sidecar of sidecars) {
346
+ const envs = await deps.coolify.listEnvs(sidecar.appId);
347
+ const envValue = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
348
+ const clientId = envValue(TOKEN_ID_ENV);
349
+ const clientSecret = envValue(TOKEN_SECRET_ENV);
350
+ if (clientId && clientSecret) {
351
+ deps.secret(clientId);
352
+ deps.secret(clientSecret);
353
+ return { clientId, clientSecret };
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+ /** Writes the token to one sidecar's env, up to {@link DELIVERY_ATTEMPTS} times; the error is dropped. */
359
+ async function deliver(deps, appId, secrets) {
360
+ for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt++) {
361
+ try {
362
+ await deps.coolify.bulkUpsertEnvs(appId, [
363
+ { key: TOKEN_ID_ENV, value: secrets.clientId, literal: true },
364
+ { key: TOKEN_SECRET_ENV, value: secrets.clientSecret, literal: true },
365
+ ]);
366
+ return true;
367
+ }
368
+ catch {
369
+ // Retried; the error is dropped in case it echoes the secret.
370
+ }
371
+ }
372
+ return false;
373
+ }
@@ -12,6 +12,16 @@ export interface PlatformService {
12
12
  hostname: string;
13
13
  gitRef: string;
14
14
  }
15
+ /**
16
+ * `archon`'s own extra field: presence turns blue/green on for platform-archon, through the same code as a hosting
17
+ * entry (`plan/blue-green.ts`). The tunnel moves to the balancer only once a balancer is recorded.
18
+ */
19
+ export interface PlatformArchonService extends PlatformService {
20
+ blueGreen?: {
21
+ steps?: number[];
22
+ stepHoldMinutes?: number;
23
+ };
24
+ }
15
25
  /**
16
26
  * `sfu`'s own extra field. A host that changes what the SFU VM is reached
17
27
  * with (its SSH key, host key or tunnel token) sets a new `connectionRevision`
@@ -97,7 +107,7 @@ export interface DeclaredPlatform {
97
107
  ai: PlatformService;
98
108
  jobs: PlatformService;
99
109
  integrations: PlatformService;
100
- archon: PlatformService;
110
+ archon: PlatformArchonService;
101
111
  /** #317 (F7). No deploy step reads this service's own `gitRef` yet -- `N1` is deployed by hand until it exists. */
102
112
  edge: PlatformService;
103
113
  /**
package/dist/platform.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isIPv4 } from 'node:net';
2
- import { validateDomain } from './org.js';
2
+ import { validateBlueGreen, validateDomain } from './org.js';
3
3
  /** Where Archon publishes the Worker script for `tag` (a private repo; the host's `fetchArtifact` fetches it). */
4
4
  export function uptimeArtifactUrl(tag) {
5
5
  return `https://github.com/wtfalch/archon/releases/download/${tag}/archon-uptime-worker.js`;
@@ -69,6 +69,9 @@ export function validatePlatform(platform) {
69
69
  return `${key}.gitRef must not be empty once ${key}.enabled is true`;
70
70
  }
71
71
  }
72
+ const blueGreenReason = validateBlueGreen(platform.archon.blueGreen, 'archon');
73
+ if (blueGreenReason)
74
+ return blueGreenReason;
72
75
  const revision = platform.sfu.connectionRevision;
73
76
  if (revision != null && !/^[A-Za-z0-9:.-]{0,64}$/.test(revision)) {
74
77
  return 'sfu.connectionRevision must be at most 64 characters of A-Z, a-z, 0-9, ":", "." and "-"';
@@ -0,0 +1,95 @@
1
+ import { type Color, type ReleaseAction, type ReleaseSignal, type WindowState } from './types.js';
2
+ /** ADR 0001 rule 5: the default steps and the least time on each. */
3
+ export declare const DEFAULT_STEPS: readonly number[];
4
+ export declare const DEFAULT_STEP_HOLD_MINUTES = 60;
5
+ /** Rule 7: errors this share of requests, with at least HARD_FAIL_MIN_REQUESTS, roll back at any level. */
6
+ export declare const HARD_FAIL_ERROR_RATE = 0.9;
7
+ export declare const HARD_FAIL_MIN_REQUESTS = 2;
8
+ /** Rule 8: a relative fail needs this many requests and this many failed checks in a row. */
9
+ export declare const RELATIVE_MIN_REQUESTS = 20;
10
+ export declare const RELATIVE_FAILED_CHECKS = 3;
11
+ /** A relative check counts only this long after the last counted one, so runs close together (applies also run releases) are one check. */
12
+ export declare const CHECK_INTERVAL_MINUTES = 10;
13
+ /** The new color matches the baseline while its error rate is at most baseline plus this (absolute). */
14
+ export declare const ERROR_RATE_MARGIN = 0.02;
15
+ /** Rules 12 and 13: the window at 100% closes after this long, and at most this long. */
16
+ export declare const WINDOW_MIN_HOURS = 24;
17
+ export declare const WINDOW_CAP_DAYS = 7;
18
+ /** The new color must be running and answer on its own address within this time of the deploy. */
19
+ export declare const DEPLOY_TIMEOUT_MINUTES = 30;
20
+ export interface ActiveRelease {
21
+ from: Color;
22
+ to: Color;
23
+ digest: string;
24
+ /** Relative-fail checks in a row. */
25
+ fails: number;
26
+ /** When the last counted relative check ran (ISO). Absent: none yet in this step. */
27
+ checkedAt?: string | undefined;
28
+ }
29
+ export interface Recorded {
30
+ live: Color;
31
+ digests: Record<Color, string | null>;
32
+ stageDigest: string | null;
33
+ weights: Record<Color, number>;
34
+ window: WindowState;
35
+ /** Set while window.kind is step, inconclusive, window or cap. */
36
+ release: ActiveRelease | null;
37
+ held: string[];
38
+ }
39
+ export interface ColorCounts {
40
+ requests: number;
41
+ errors: number;
42
+ }
43
+ /** The reporting health read (C1), once validated. */
44
+ export interface HealthRead {
45
+ colors: Record<Color, ColorCounts>;
46
+ daily: {
47
+ days: number;
48
+ requestsPerDay: number;
49
+ errorsPerDay: number;
50
+ };
51
+ }
52
+ export interface Observation {
53
+ /** null when the health read failed: counted as inconclusive, never as a pass. */
54
+ health: HealthRead | null;
55
+ /** The new color's Coolify status. */
56
+ status: 'running' | 'crash_loop' | 'other';
57
+ /** The new color's own address, checked over HTTP. */
58
+ address: 'ok' | 'failed' | 'unchecked';
59
+ }
60
+ export type ReleaseEffect = {
61
+ kind: 'deploy';
62
+ color: Color;
63
+ digest: string;
64
+ } | {
65
+ kind: 'stop';
66
+ color: Color;
67
+ };
68
+ export interface Decision {
69
+ recorded: Recorded;
70
+ signal: ReleaseSignal | null;
71
+ effects: ReleaseEffect[];
72
+ /** Blocked items for the operator, fixed text. */
73
+ blocked: string[];
74
+ /** Actions that changed nothing, with a fixed reason. */
75
+ refused: string[];
76
+ }
77
+ export declare function normalizeSteps(steps: readonly number[] | undefined): number[];
78
+ /** The time a health read should cover: from the step or window start to now. */
79
+ export declare function healthRange(r: Recorded, now: Date): {
80
+ from: string;
81
+ to: string;
82
+ } | null;
83
+ /**
84
+ * The whole release decision for one run: from the recorded state, the
85
+ * actions requested, one observation and the time, the next recorded state.
86
+ * Pure: it reads nothing and writes nothing (ADR 0001 rules 5, 7-13, 16).
87
+ */
88
+ export declare function decide(input: {
89
+ recorded: Recorded;
90
+ actions: readonly ReleaseAction[];
91
+ steps?: readonly number[] | undefined;
92
+ stepHoldMinutes?: number | undefined;
93
+ now: Date;
94
+ observation: Observation;
95
+ }): Decision;