@wtfalch/foundry 0.49.0 → 0.50.1

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/index.d.ts CHANGED
@@ -31,6 +31,11 @@ export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINAT
31
31
  export { AdoptRefusedError } from './plan/index.js';
32
32
  export { DumpError, dumpNow } from './plan/index.js';
33
33
  export type { DatabaseDump } from './plan/index.js';
34
+ export { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_IMAGE_REF, stageMailEnv, stageSinkAppName, } from './plan/index.js';
35
+ export { STAGE_COPY_EXCLUDED_TABLES, StageCopyFailedError, StageCopyRefusedError, stageCopy, tableExcluded, undoStageCopy, } from './plan/index.js';
36
+ export type { StageCopyInput, StageCopyPort, StageCopyProduction, StageCopyRunRecord, UndoStageCopyInput, } from './plan/index.js';
37
+ export { StageRestoreError, StageRestoreRefusedError, createStageCopyPort, execRunner, } from './plan/index.js';
38
+ export type { Runner, StageCopyPortDeps } from './plan/index.js';
34
39
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
35
40
  export type { OrgPostgresMigrationConfirmation } from './plan/index.js';
36
41
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
package/dist/index.js CHANGED
@@ -19,6 +19,9 @@ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAudit
19
19
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
20
20
  export { AdoptRefusedError } from './plan/index.js';
21
21
  export { DumpError, dumpNow } from './plan/index.js';
22
+ export { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_IMAGE_REF, stageMailEnv, stageSinkAppName, } from './plan/index.js';
23
+ export { STAGE_COPY_EXCLUDED_TABLES, StageCopyFailedError, StageCopyRefusedError, stageCopy, tableExcluded, undoStageCopy, } from './plan/index.js';
24
+ export { StageRestoreError, StageRestoreRefusedError, createStageCopyPort, execRunner, } from './plan/index.js';
22
25
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
23
26
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
24
27
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
@@ -59,6 +59,7 @@ export async function applyArchonBlueGreen(run) {
59
59
  logDir: run.logDir,
60
60
  isRunning: run.isRunning,
61
61
  adoptRunningBlue: true,
62
+ stageSink: { slug: 'platform' },
62
63
  actionsKey: async () => {
63
64
  const key = await mintActionsKey({
64
65
  id,
@@ -7,6 +7,8 @@ import { type StageGuardDeps } from './stage-guards.js';
7
7
  /** The Coolify calls this module makes. */
8
8
  type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'deployAndWait' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage' | 'requireGithubApp' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw'> & {
9
9
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
10
+ /** Needed by the stage mail sink; a host without it gets no sink, so no stage mail env. */
11
+ createDockerImageApplication?: CoolifyPort['createDockerImageApplication'];
10
12
  };
11
13
  export interface BlueGreenApplyDeps {
12
14
  coolify: Coolify;
@@ -47,6 +49,13 @@ export interface BlueGreenApplyDeps {
47
49
  * guard unrecorded ("not run", never passed), so `stageGuardsReady` stays false and the copy does not start.
48
50
  */
49
51
  stageGuards?: StageGuardDeps;
52
+ /**
53
+ * The company the stage mail sink belongs to (its name is `<slug>-stage-sink`). Absent: no sink is made and
54
+ * stage holds no mail env at all, so the copy stays refused.
55
+ */
56
+ stageSink?: {
57
+ slug: string;
58
+ };
50
59
  }
51
60
  /** What writing a balancer's config needs; {@link BlueGreenApplyDeps} and the release runner both have it. */
52
61
  export interface LbConfigDeps {
@@ -1,13 +1,18 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { ACTIONS_KEY_ENV, COLORS, LB_CONFIG_PATH, bgKey, colorAppName, colorBackend, formatWeights, lbAppName, lbDockerfile, parseWeights, prodResources, renderLbConfig, reportingEnv, stageAppName, stageAppUrl, stageKindOf, stageResources, structureHash, } from './blue-green.js';
3
3
  import { recordBuiltCommit } from './build-record.js';
4
+ import { withTimeout } from './call-timeout.js';
4
5
  import { ensureConnectorsOnServers } from './cloudflared-connector.js';
5
6
  import { waitForNewDeployment } from './deploy-health.js';
7
+ import { commandHealthCheckSettings, wgetProbe } from './health-check-settings.js';
6
8
  import { ENTRY_CONTAINER_LABELS } from './proxy-labels.js';
7
9
  import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js';
8
10
  import { buildStageEnv } from './stage-env.js';
9
11
  import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
10
12
  import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
13
+ import { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_APP_SCOPE_KEY, STAGE_SINK_HTTP_PORT, STAGE_SINK_IMAGE_TAG, STAGE_SINK_REPOSITORY, stageMailEnv, stageSinkAppName, } from './stage-sink.js';
14
+ /** How long one Coolify call of the sink may take. */
15
+ const SINK_CALL_TIMEOUT_MS = 30_000;
11
16
  /** Env names whose value is a credential: registered before they are copied. */
12
17
  const SECRETISH = /(KEY|SECRET|TOKEN|PASSWORD|DATABASE_URL)/;
13
18
  /** The env names that name the database. */
@@ -80,8 +85,15 @@ export async function applyBlueGreenServer(deps, subject, server) {
80
85
  const lbName = lbAppName(subject.base);
81
86
  const lbId = await ensureLoadBalancer(deps, subject, server.uuid, lbName, isPrimary);
82
87
  await writeLbConfig(deps, subject, { uuid: server.uuid, isPrimary }, lbId, weights);
83
- if (isPrimary)
88
+ if (isPrimary) {
89
+ const sinkReady = await ensureStageSink(deps, subject, server.uuid);
84
90
  await ensureStage(deps, subject, server.uuid);
91
+ if (!sinkReady) {
92
+ // The planner plans this step again while the state is `blocked:`; ensureStage may have written `ok`.
93
+ deps.record(bgKey.stageState(id), 'blocked:sink');
94
+ await deps.persist();
95
+ }
96
+ }
85
97
  deps.record(bgKey.applied(id, server.uuid), structureHash(subject, { primary: isPrimary }));
86
98
  await deps.persist();
87
99
  }
@@ -301,6 +313,69 @@ async function ensureLoadBalancer(deps, subject, serverUuid, name, isPrimary) {
301
313
  }
302
314
  return lbId;
303
315
  }
316
+ /**
317
+ * One Mailpit app per company beside stage, shared by every stage of the company. It is checked against the live
318
+ * app on every run: one that is stopped or gone is deployed again. `stage_sink_applied` is recorded only after
319
+ * the sink deploys; a failure clears it, logs fixed text and the error name, and returns false. Production's
320
+ * structure goes on (stage then holds no mail env and the copy stays refused).
321
+ */
322
+ async function ensureStageSink(deps, subject, serverUuid) {
323
+ const org = deps.stageSink;
324
+ if (!org)
325
+ return true;
326
+ const { coolify } = deps;
327
+ const name = stageSinkAppName(org);
328
+ try {
329
+ let appId = deps.recorded()[STAGE_SINK_APP_SCOPE_KEY];
330
+ let created = false;
331
+ if (!appId) {
332
+ appId = (await withTimeout('stage sink: find the app', SINK_CALL_TIMEOUT_MS, () => coolify.findApplicationByName(name, { environmentIds: [deps.environment.id] })))?.uuid;
333
+ }
334
+ if (!appId) {
335
+ const createImageApp = coolify.createDockerImageApplication;
336
+ if (!createImageApp)
337
+ throw new Error('the host cannot create an image app');
338
+ appId = (await withTimeout('stage sink: create the app', SINK_CALL_TIMEOUT_MS, () => createImageApp.call(coolify, {
339
+ project_uuid: deps.projectUuid,
340
+ server_uuid: serverUuid,
341
+ environment_name: deps.environment.name,
342
+ environment_uuid: deps.environment.uuid,
343
+ docker_registry_image_name: STAGE_SINK_REPOSITORY,
344
+ docker_registry_image_tag: STAGE_SINK_IMAGE_TAG,
345
+ ports_exposes: String(STAGE_SINK_HTTP_PORT),
346
+ name,
347
+ description: `mail sink for ${subject.base}'s stage`,
348
+ instant_deploy: false,
349
+ }))).uuid;
350
+ created = true;
351
+ }
352
+ // Recorded before anything else is written, so a later failure never leaves an app nothing points at.
353
+ if (deps.recorded()[STAGE_SINK_APP_SCOPE_KEY] !== appId) {
354
+ deps.record(STAGE_SINK_APP_SCOPE_KEY, appId);
355
+ await deps.persist();
356
+ }
357
+ if (created) {
358
+ await withTimeout('stage sink: set the app up', SINK_CALL_TIMEOUT_MS, () => coolify.updateApplication(appId, {
359
+ custom_internal_name: name,
360
+ ...commandHealthCheckSettings(wgetProbe(STAGE_SINK_HTTP_PORT, '/livez')),
361
+ }));
362
+ }
363
+ await ensureDeployed(deps, appId, name);
364
+ }
365
+ catch (error) {
366
+ console.error(`${deps.label}: the stage mail sink failed (${error instanceof Error ? error.name : 'unknown'}); stage gets no mail env`);
367
+ if (deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY]) {
368
+ deps.record(STAGE_SINK_APPLIED_SCOPE_KEY, '');
369
+ await deps.persist();
370
+ }
371
+ return false;
372
+ }
373
+ if (!deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY]) {
374
+ deps.record(STAGE_SINK_APPLIED_SCOPE_KEY, '1');
375
+ await deps.persist();
376
+ }
377
+ return true;
378
+ }
304
379
  /** The stage app and its own empty database, on the primary. Its env comes from the allowlist, never from a copy of the live app's. */
305
380
  async function ensureStage(deps, subject, serverUuid) {
306
381
  const { coolify } = deps;
@@ -368,6 +443,12 @@ async function ensureStage(deps, subject, serverUuid) {
368
443
  deps.secret(key);
369
444
  stageEnv.push({ key: ACTIONS_KEY_ENV, value: key, buildTime: true });
370
445
  }
446
+ // The sink's mail env, only once the sink app is deployed. Before that stage holds no mail env at all.
447
+ const sinkApplied = deps.stageSink && deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY];
448
+ const mail = deps.stageSink && sinkApplied ? stageMailEnv(deps.stageSink) : [];
449
+ const stageHas = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
450
+ const mailChanged = mail.some((e) => stageHas.get(e.key) !== e.value);
451
+ stageEnv.push(...mail);
371
452
  const stageSurface = {
372
453
  env: stageEnv.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
373
454
  resources: stageResources(subject),
@@ -382,6 +463,7 @@ async function ensureStage(deps, subject, serverUuid) {
382
463
  internalHosts: Object.values(prodResources(subject)),
383
464
  },
384
465
  stage: stageSurface,
466
+ sinkMail: Object.fromEntries(mail.map((e) => [e.key, e.value])),
385
467
  });
386
468
  if (violations.length > 0) {
387
469
  // Stage fails closed for itself: nothing is written to it, production's structure goes on.
@@ -423,11 +505,13 @@ async function ensureStage(deps, subject, serverUuid) {
423
505
  return;
424
506
  }
425
507
  await upsertEnv(deps, appId, stageEnv);
508
+ if (mail.length > 0)
509
+ deps.record(bgKey.stageSink(subject.id), 'applied');
426
510
  deps.record(bgKey.stageIsolation(subject.id), isolationHash);
427
511
  deps.record(bgKey.stageProbes(subject.id), probes.value);
428
512
  await deps.persist();
429
513
  const live = await coolify.getApplication(appId);
430
- if (live.status === undefined || !deps.isRunning(live.status)) {
514
+ if (mailChanged || live.status === undefined || !deps.isRunning(live.status)) {
431
515
  // Stage is a test copy: a failed build or health check must not fail production's structure apply.
432
516
  // The next apply retries it (the plan keeps the step while the state is `unhealthy`).
433
517
  let built;
@@ -54,6 +54,12 @@ export declare const bgKey: {
54
54
  stageGate: (id: string) => string;
55
55
  /** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
56
56
  stageProbes: (id: string) => string;
57
+ /** `applied` once the stage app holds the sink's mail env. The copy refuses without it. */
58
+ stageSink: (id: string) => string;
59
+ /** `<ISO time>|<dump execution id>|ok`, written by a finished copy of production into stage. */
60
+ stageCopy: (id: string) => string;
61
+ /** The Coolify database stage ran on before the last copy, kept for `undoStageCopy` until the next copy. */
62
+ stageKept: (id: string) => string;
57
63
  };
58
64
  /** The three env names the reporting health read uses (contract C1). */
59
65
  export declare const REPORTING_ENV: {
@@ -263,6 +269,8 @@ export interface PlanBlueGreenInput {
263
269
  appPlanned: (entryKey: string) => boolean;
264
270
  /** The platform's recorded Access provider, which the stage gate requires. Absent: no gate is made. */
265
271
  accessProviderId?: string | null;
272
+ /** The company's stage mail sink is recorded and its live app is stopped or gone (read from Coolify at plan time). */
273
+ stageSinkDown?: boolean;
266
274
  }
267
275
  /**
268
276
  * Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
@@ -8,6 +8,7 @@ import { appNameFor, appStepKey } from './platform-diff.js';
8
8
  import { remoteDatabaseAppliedFor } from './remote-database.js';
9
9
  import { buildStageEnv } from './stage-env.js';
10
10
  import { assertStageIsolated, envClass } from './stage-isolation.js';
11
+ import { STAGE_SINK_VERSION } from './stage-sink.js';
11
12
  export const COLORS = ['blue', 'green'];
12
13
  /** The name of `base`'s copy in `color`: `<base>-blue`. */
13
14
  export function colorAppName(base, color) {
@@ -62,6 +63,12 @@ export const bgKey = {
62
63
  stageGate: (id) => `bg.${id}.stagegate`,
63
64
  /** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
64
65
  stageProbes: (id) => `bg.${id}.stageprobes`,
66
+ /** `applied` once the stage app holds the sink's mail env. The copy refuses without it. */
67
+ stageSink: (id) => `bg.${id}.stagesink`,
68
+ /** `<ISO time>|<dump execution id>|ok`, written by a finished copy of production into stage. */
69
+ stageCopy: (id) => `bg.${id}.stagecopy`,
70
+ /** The Coolify database stage ran on before the last copy, kept for `undoStageCopy` until the next copy. */
71
+ stageKept: (id) => `bg.${id}.stagekept`,
65
72
  };
66
73
  /** The three env names the reporting health read uses (contract C1). */
67
74
  export const REPORTING_ENV = {
@@ -324,6 +331,7 @@ export function structureHash(subject, input) {
324
331
  database: subject.database,
325
332
  primary: input.primary,
326
333
  lb: VERSIONS.traefik,
334
+ ...(input.primary ? { stageSink: STAGE_SINK_VERSION } : {}),
327
335
  }))
328
336
  .digest('hex')
329
337
  .slice(0, 16);
@@ -498,6 +506,12 @@ export function planBlueGreen(input) {
498
506
  key: `bg:${id}:stage`,
499
507
  });
500
508
  }
509
+ else if (stageState === 'blocked:sink' || input.stageSinkDown === true) {
510
+ unmanaged.push({
511
+ description: `${org.slug}'s ${id} app: the stage mail sink is not running; stage gets no mail env and the copy is refused`,
512
+ key: `bg:${id}:stage`,
513
+ });
514
+ }
501
515
  else if (stageState?.startsWith('blocked:')) {
502
516
  unmanaged.push({
503
517
  description: `${org.slug}'s ${id} app: stage is blocked and was not written to (${stageState.slice('blocked:'.length)})`,
@@ -516,7 +530,10 @@ export function planBlueGreen(input) {
516
530
  continue;
517
531
  }
518
532
  // A stage that was not healthy is retried by running the structure again.
519
- const stageRetry = isPrimary && (stageState === 'unhealthy' || stageState?.startsWith('blocked:') === true);
533
+ const stageRetry = isPrimary &&
534
+ (stageState === 'unhealthy' ||
535
+ stageState?.startsWith('blocked:') === true ||
536
+ input.stageSinkDown === true);
520
537
  if (recordedIds[bgKey.applied(id, server)] !== hash || stageRetry) {
521
538
  steps.push({
522
539
  kind: 'bluegreen',
@@ -0,0 +1,6 @@
1
+ /** A failed outside call: fixed text and the label, never the cause (it can echo a credential). */
2
+ export declare class CallError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** Runs one outside call under a timeout. Any failure becomes a {@link CallError} with fixed text. */
6
+ export declare function withTimeout<T>(label: string, timeoutMs: number, call: () => Promise<T>): Promise<T>;
@@ -0,0 +1,27 @@
1
+ /** A failed outside call: fixed text and the label, never the cause (it can echo a credential). */
2
+ export class CallError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'CallError';
6
+ }
7
+ }
8
+ /** Runs one outside call under a timeout. Any failure becomes a {@link CallError} with fixed text. */
9
+ export async function withTimeout(label, timeoutMs, call) {
10
+ let timer;
11
+ try {
12
+ return await Promise.race([
13
+ call(),
14
+ new Promise((_, reject) => {
15
+ timer = setTimeout(() => reject(new CallError(`${label}: no answer within ${timeoutMs / 1000} seconds`)), timeoutMs);
16
+ }),
17
+ ]);
18
+ }
19
+ catch (error) {
20
+ if (error instanceof CallError)
21
+ throw error;
22
+ throw new CallError(`${label}: the call failed`);
23
+ }
24
+ finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
@@ -136,6 +136,8 @@ export interface PlanOrgOptions {
136
136
  backupStorageUuid?: string | null;
137
137
  /** Every Coolify server the host lists, for blue/green (`plan/blue-green.ts`). Absent: the host cannot list them. */
138
138
  blueGreenServers?: readonly BlueGreenServer[];
139
+ /** The company's stage mail sink is recorded and its live app is stopped or gone. */
140
+ stageSinkDown?: boolean;
139
141
  }
140
142
  export interface PlanDetail {
141
143
  orgId: OrgId;
package/dist/plan/diff.js CHANGED
@@ -491,6 +491,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
491
491
  servers: options.blueGreenServers,
492
492
  canHoldSecrets: platformReadiness.canHoldSecrets === true,
493
493
  accessProviderId: platformReadiness.accessProviderId ?? null,
494
+ ...(options.stageSinkDown ? { stageSinkDown: true } : {}),
494
495
  appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
495
496
  p.action.kind === 'app' &&
496
497
  p.action.key === appStepKey(org, entryKey)),
@@ -2374,6 +2374,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2374
2374
  * blue copy is not recorded. The health token is only read here, never minted.
2375
2375
  */
2376
2376
  releaseHost(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
2377
+ /** Whether the stage mail sink app is stopped or gone, read live. A failed read throws; the caller reads that as up. */
2378
+ stageSinkDown(appId: string): Promise<boolean>;
2377
2379
  /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2378
2380
  blueGreenServers(): Promise<BlueGreenServer[]>;
2379
2381
  /**
@@ -2905,6 +2905,17 @@ export class FoundryStepExecutor {
2905
2905
  label: `coolify:release:${subject.id}`,
2906
2906
  });
2907
2907
  }
2908
+ /** Whether the stage mail sink app is stopped or gone, read live. A failed read throws; the caller reads that as up. */
2909
+ async stageSinkDown(appId) {
2910
+ try {
2911
+ return !isRunningOrStarting((await this.ports.coolify.getApplication(appId)).status);
2912
+ }
2913
+ catch (error) {
2914
+ if (isNotFoundError(error))
2915
+ return true;
2916
+ throw error;
2917
+ }
2918
+ }
2908
2919
  /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2909
2920
  async blueGreenServers() {
2910
2921
  return (await this.ports.coolify.listServers()).map((s) => ({
@@ -3014,6 +3025,7 @@ export class FoundryStepExecutor {
3014
3025
  logDir: LOG_DIR,
3015
3026
  isRunning: isRunningOrStarting,
3016
3027
  stageGuards: this.stageGuards(org, subject, recorded(), action.desired.accessProviderId),
3028
+ stageSink: { slug: org.slug },
3017
3029
  };
3018
3030
  if (action.kind === 'bluegreen-lb') {
3019
3031
  await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
@@ -9324,9 +9336,21 @@ export class FoundryStepExecutor {
9324
9336
  }
9325
9337
  return;
9326
9338
  }
9327
- case 'coolify:bg-database':
9339
+ case 'coolify:bg-database': {
9340
+ // Copies of production (`<base>-stage-db-copy-*`) are never all recorded; they go with the org.
9341
+ const all = await this.ports.coolify.listDatabases();
9342
+ const name = all.find((d) => d.uuid === target.id)?.name;
9343
+ const base = name ? /^(.+)-stage-db(?:-copy-.+)?$/.exec(name)?.[1] : undefined;
9344
+ if (base) {
9345
+ for (const d of all) {
9346
+ if (d.uuid !== target.id && d.name.startsWith(`${base}-stage-db-copy-`)) {
9347
+ await this.ports.coolify.deleteDatabase(d.uuid);
9348
+ }
9349
+ }
9350
+ }
9328
9351
  await this.ports.coolify.deleteDatabase(target.id);
9329
9352
  return;
9353
+ }
9330
9354
  case 'coolify:database':
9331
9355
  await this.ports.coolify.deleteDatabase(target.id);
9332
9356
  return;
@@ -41,6 +41,11 @@ export { createZitadelOrgMetadata } from './zitadel-org-metadata.js';
41
41
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
42
42
  export { DumpError, dumpNow } from './database-dump.js';
43
43
  export type { DatabaseDump } from './database-dump.js';
44
+ export { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_IMAGE_REF, STAGE_SINK_SMTP_PORT, stageMailEnv, stageSinkAppName, stageSinkInternalUrl, } from './stage-sink.js';
45
+ export { STAGE_COPY_EXCLUDED_TABLES, StageCopyFailedError, StageCopyRefusedError, stageCopy, tableExcluded, undoStageCopy, } from './stage-copy.js';
46
+ export type { StageCopyInput, StageCopyPort, StageCopyProduction, StageCopyRefusalReason, StageCopyRunRecord, UndoStageCopyInput, } from './stage-copy.js';
47
+ export { StageRestoreError, StageRestoreRefusedError, createStageCopyPort, execRunner, } from './stage-copy-port.js';
48
+ export type { Runner, StageCopyPortDeps } from './stage-copy-port.js';
44
49
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
45
50
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
46
51
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
@@ -26,6 +26,9 @@ export { createZitadelProjectRoles } from './zitadel-project-roles.js';
26
26
  // implements over held keys and its own SSH client, the same shape the rest
27
27
  // of `platform-providers.ts` uses for Cloudflare.
28
28
  export { DumpError, dumpNow } from './database-dump.js';
29
+ export { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_IMAGE_REF, STAGE_SINK_SMTP_PORT, stageMailEnv, stageSinkAppName, stageSinkInternalUrl, } from './stage-sink.js';
30
+ export { STAGE_COPY_EXCLUDED_TABLES, StageCopyFailedError, StageCopyRefusedError, stageCopy, tableExcluded, undoStageCopy, } from './stage-copy.js';
31
+ export { StageRestoreError, StageRestoreRefusedError, createStageCopyPort, execRunner, } from './stage-copy-port.js';
29
32
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
30
33
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
31
34
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
@@ -219,6 +219,8 @@ export interface StepExecutor {
219
219
  usable: boolean;
220
220
  reachable: boolean;
221
221
  }>>;
222
+ /** Whether the company's stage mail sink app (by Coolify id) is stopped or gone, read live. Optional: an executor without it reads the sink as up. */
223
+ stageSinkDown?(appId: string): Promise<boolean>;
222
224
  /**
223
225
  * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`); `null` while its blue
224
226
  * copy is not recorded. Optional: an executor without it runs no release.
@@ -1313,6 +1313,10 @@ export class FoundryPlanApply {
1313
1313
  const blueGreenServers = declaresBlueGreen(org) && this.executor.blueGreenServers
1314
1314
  ? await this.executor.blueGreenServers().catch(() => undefined)
1315
1315
  : undefined;
1316
+ const sinkId = recorded.stage_sink_app;
1317
+ const stageSinkDown = sinkId && recorded.stage_sink_applied && declaresBlueGreen(org) && this.executor.stageSinkDown
1318
+ ? await this.executor.stageSinkDown(sinkId).catch(() => false)
1319
+ : false;
1316
1320
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
1317
1321
  loginHostBindings: this.loginHostBindings,
1318
1322
  loginHostClientOrganizations: this.loginHostClientOrganizations,
@@ -1343,6 +1347,7 @@ export class FoundryPlanApply {
1343
1347
  customersDirectory: this.executor.customersDirectoryAvailable?.() ?? false,
1344
1348
  bouleArchonCredential: this.executor.bouleArchonCredentialAvailable?.() ?? false,
1345
1349
  ...(blueGreenServers ? { blueGreenServers } : {}),
1350
+ ...(stageSinkDown ? { stageSinkDown } : {}),
1346
1351
  ...(blueGreenServers && org.hosting.entries.some((e) => e.blueGreen && e.database)
1347
1352
  ? {
1348
1353
  remoteDatabase: remoteDatabaseFactsFrom({
@@ -2137,9 +2142,9 @@ function destroyStepDescriptions(targets) {
2137
2142
  'delete the declared org',
2138
2143
  ];
2139
2144
  }
2140
- /** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
2141
- const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
2142
- const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
2145
+ /** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server, the company's stage mail sink (`stage_sink_app`). */
2146
+ const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+|stage_sink_app)$/;
2147
+ const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.(stagedb|stagekept)$/;
2143
2148
  /** Stage's Access gate (`bg.<entry>.stagegate`), and the database route's Cloudflare parts: its Access app, its service token (also one minted and never delivered) and its DNS record (recorded as the route). */
2144
2149
  const BLUE_GREEN_EDGE_KEYS = [
2145
2150
  [/^bgdb\.[^.:]+\.access$/, 'bg-access'],
@@ -4016,8 +4016,8 @@ async function switchOnArchonIdTokenUserinfo(context, deps) {
4016
4016
  try {
4017
4017
  done = await zitadel.updateOidcAppIdTokenUserinfoAssertion(orgId, projectId, appId);
4018
4018
  }
4019
- catch {
4020
- throw new Error(`platform:archon: switching on user info in the ID token failed for the sign-in client ${appId}`);
4019
+ catch (error) {
4020
+ throw new Error(`platform:archon: switching on user info in the ID token failed for the sign-in client ${appId}${zitadelRefusal(error)}`);
4021
4021
  }
4022
4022
  // A client that answers every property (`packages/local`) resolves to something else: no marker.
4023
4023
  if (typeof done !== 'boolean')
@@ -4052,13 +4052,29 @@ async function updateArchonPostLogoutUris(context, deps) {
4052
4052
  try {
4053
4053
  await zitadel.updateOidcAppPostLogoutRedirectUris(orgId, projectId, appId, uris);
4054
4054
  }
4055
- catch {
4056
- throw new Error(`platform:archon: updating the post-logout addresses failed for the sign-in client ${appId}`);
4055
+ catch (error) {
4056
+ throw new Error(`platform:archon: updating the post-logout addresses failed for the sign-in client ${appId}${zitadelRefusal(error)}`);
4057
4057
  }
4058
4058
  context.recordScopeId(PLATFORM_ARCHON_POST_LOGOUT_APPLIED_SCOPE_KEY, postLogoutAppliedValue(appId, uris));
4059
4059
  await context.persist?.();
4060
4060
  return {};
4061
4061
  }
4062
+ /**
4063
+ * What ZITADEL answered, for an error about one of Archon's sign-in client repairs: its HTTP status,
4064
+ * gRPC code and own message (ZITADEL's error ids, never a token or a request body), capped. Empty for
4065
+ * anything that is not a ZITADEL answer, so a network failure names no cause it cannot vouch for.
4066
+ */
4067
+ function zitadelRefusal(error) {
4068
+ if (!(error instanceof Error) || error.name !== 'ZitadelError')
4069
+ return '';
4070
+ const { status, code } = error;
4071
+ const parts = [
4072
+ typeof status === 'number' ? `HTTP ${status}` : '',
4073
+ typeof code === 'number' ? `code ${code}` : '',
4074
+ ];
4075
+ const said = error.message.slice(0, 200);
4076
+ return ` (ZITADEL answered ${[...parts.filter(Boolean), said].join(', ')})`;
4077
+ }
4062
4078
  /**
4063
4079
  * #339: registers `/auth/desktop/callback` on Archon's sign-in client
4064
4080
  * (`zitadel:desktop-callback` keyed {@link PLATFORM_ARCHON_KEY}), keeping the
@@ -4089,8 +4105,8 @@ async function registerArchonDesktopCallback(context, deps) {
4089
4105
  await zitadel.updateOidcAppRedirectUris(orgId, projectId, appId, union);
4090
4106
  }
4091
4107
  }
4092
- catch {
4093
- throw new Error(`platform:archon: registering the desktop callback failed for the sign-in client ${appId}`);
4108
+ catch (error) {
4109
+ throw new Error(`platform:archon: registering the desktop callback failed for the sign-in client ${appId}${zitadelRefusal(error)}`);
4094
4110
  }
4095
4111
  context.recordScopeId(PLATFORM_ARCHON_DESKTOP_CALLBACK_APPLIED_SCOPE_KEY, appId);
4096
4112
  await context.persist?.();
@@ -0,0 +1,62 @@
1
+ import type { DatabaseDump } from './database-dump.js';
2
+ import { type StageCopyPort } from './stage-copy.js';
3
+ /** Thrown before anything is run or fetched. Fixed text: never the URL it refused. */
4
+ export declare class StageRestoreRefusedError extends Error {
5
+ constructor(reason: string);
6
+ }
7
+ /** Thrown when a step of the restore failed. Fixed text only: the cause can echo a connection URL. */
8
+ export declare class StageRestoreError extends Error {
9
+ constructor(step: string);
10
+ }
11
+ export interface RunResult {
12
+ exitCode: number;
13
+ stdout: string;
14
+ }
15
+ /** Runs a program with exactly `env` (nothing inherited) and kills it after `timeoutMs`. */
16
+ export type Runner = (command: string, args: readonly string[], options: {
17
+ env: Readonly<Record<string, string>>;
18
+ timeoutMs: number;
19
+ /** Kills the program when aborted. */
20
+ signal?: AbortSignal;
21
+ }) => Promise<RunResult>;
22
+ /** The default {@link Runner}: `execFile`, stderr dropped, no shell. */
23
+ export declare const execRunner: Runner;
24
+ export interface StageCopyPortTimeouts {
25
+ /** Saving the dump from the backup bucket. Default 10 minutes. */
26
+ fetchMs?: number;
27
+ /** Reading the dump's table of contents. Default 2 minutes. */
28
+ listMs?: number;
29
+ /** `pg_restore`. Default 30 minutes. */
30
+ restoreMs?: number;
31
+ /** Creating or syncing the runtime role. Default 2 minutes. */
32
+ roleMs?: number;
33
+ }
34
+ export interface StageCopyPortDeps {
35
+ /** Every connection URL production's database answers at. A target on the same host, port and database is refused. */
36
+ productionUrls: () => Promise<readonly string[]>;
37
+ /** Saves the dump from the backup bucket to a local file; `remove` deletes it. */
38
+ fetchDump: (location: DatabaseDump['location'], signal?: AbortSignal) => Promise<{
39
+ path: string;
40
+ remove: () => Promise<void>;
41
+ }>;
42
+ run?: Runner;
43
+ /** Default: `@wtfalch/db/runtime-role`'s `ensureRuntimeRole`. */
44
+ ensureRole?: (options: {
45
+ ownerUrl: string;
46
+ runtimeUrl: string;
47
+ log: () => void;
48
+ }) => Promise<unknown>;
49
+ timeouts?: StageCopyPortTimeouts;
50
+ }
51
+ /** The table of contents of a dump without the data of the excluded tables. */
52
+ export declare function filterToc(toc: string, patterns: readonly string[]): {
53
+ list: string;
54
+ excluded: number;
55
+ };
56
+ /**
57
+ * The concrete {@link StageCopyPort}. It restores a dump into a stage database and sets up that database's runtime
58
+ * role, and refuses any target that is not a stage database or that is production's. Connection settings reach
59
+ * the programs as `PG*` env, never as arguments. Every outside call has a timeout, and an error carries fixed text
60
+ * only.
61
+ */
62
+ export declare function createStageCopyPort(deps: StageCopyPortDeps): StageCopyPort;