@wtfalch/foundry 0.21.1 → 0.22.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.
@@ -135,6 +135,15 @@ export declare const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = "archon
135
135
  * values until someone redeploys by hand (#467).
136
136
  */
137
137
  export declare const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = "archon_envs_deployed_hash";
138
+ /**
139
+ * The uuid of the deployment Archon's step triggered without waiting
140
+ * (`PlatformArchonDeps.runsInsideApp`), `pending` between the trigger and the
141
+ * moment its uuid is known. The next plan reads that deployment's status:
142
+ * running blocks the step, failed or unknown plans it again, finished lets the
143
+ * next run of the step confirm it (the integrations key-synced marker) and
144
+ * clear this.
145
+ */
146
+ export declare const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = "archon_deployment";
138
147
  /**
139
148
  * #211 round 4: the broker app id Archon last registered its entry with.
140
149
  * `provisionPlatformArchon` reads the broker to register with from
@@ -414,6 +423,16 @@ export declare function platformMailMxHost(apex: string): string;
414
423
  */
415
424
  export declare const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = "mail_applied_apex";
416
425
  export declare const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = "mail_applied_host";
426
+ /**
427
+ * #554: the mode and `usesDomain` the step last applied. No reader lists a
428
+ * live `coolify mail <org>` resource, so `planMail` decides "applied" from
429
+ * the record. Records written before these keys existed lack them; a
430
+ * missing mode vouches for `hosted` only, a missing `usesDomain` only when
431
+ * the declared domain equals the recorded `mail_domain`. A park records
432
+ * mode `off`.
433
+ */
434
+ export declare const ORG_MAIL_APPLIED_MODE_SCOPE_KEY = "mail_applied_mode";
435
+ export declare const ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY = "mail_applied_uses_domain";
417
436
  /**
418
437
  * The key of a hosting entry in a `coolify:app` or `cloudflare:access` step
419
438
  * and in the ids recorded for it: `<orgId>:<entry key>`, so a second entry
@@ -845,6 +864,15 @@ export interface PlanPlatformOptions {
845
864
  * steps" note in `platform-providers.ts`'s `FoundryPlatformStepExecutor.blockedSteps`.
846
865
  */
847
866
  heartbeatsApiKeyMissing?: boolean;
867
+ /**
868
+ * The host runs inside Archon's app (`PlatformArchonDeps.runsInsideApp`):
869
+ * Archon's step redeploys it, so it is ordered last in the plan, after
870
+ * every step that would otherwise be lost when the old container stops.
871
+ * Not a block.
872
+ */
873
+ archonRunsInsideApp?: boolean;
874
+ /** Why Archon's step must wait: the deployment it triggered is still running. Blocks even an applied step. */
875
+ archonDeployRunning?: string;
848
876
  };
849
877
  /**
850
878
  * Steps whose live state differs from the declared one in a way the recorded
@@ -156,6 +156,15 @@ export const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = 'archon_integra
156
156
  * values until someone redeploys by hand (#467).
157
157
  */
158
158
  export const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = 'archon_envs_deployed_hash';
159
+ /**
160
+ * The uuid of the deployment Archon's step triggered without waiting
161
+ * (`PlatformArchonDeps.runsInsideApp`), `pending` between the trigger and the
162
+ * moment its uuid is known. The next plan reads that deployment's status:
163
+ * running blocks the step, failed or unknown plans it again, finished lets the
164
+ * next run of the step confirm it (the integrations key-synced marker) and
165
+ * clear this.
166
+ */
167
+ export const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = 'archon_deployment';
159
168
  /**
160
169
  * #211 round 4: the broker app id Archon last registered its entry with.
161
170
  * `provisionPlatformArchon` reads the broker to register with from
@@ -451,6 +460,16 @@ export function platformMailMxHost(apex) {
451
460
  */
452
461
  export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
453
462
  export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
463
+ /**
464
+ * #554: the mode and `usesDomain` the step last applied. No reader lists a
465
+ * live `coolify mail <org>` resource, so `planMail` decides "applied" from
466
+ * the record. Records written before these keys existed lack them; a
467
+ * missing mode vouches for `hosted` only, a missing `usesDomain` only when
468
+ * the declared domain equals the recorded `mail_domain`. A park records
469
+ * mode `off`.
470
+ */
471
+ export const ORG_MAIL_APPLIED_MODE_SCOPE_KEY = 'mail_applied_mode';
472
+ export const ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY = 'mail_applied_uses_domain';
454
473
  /**
455
474
  * The key of a hosting entry in a `coolify:app` or `cloudflare:access` step
456
475
  * and in the ids recorded for it: `<orgId>:<entry key>`, so a second entry
@@ -969,8 +988,10 @@ export function planPlatform(platform, live, options = {}) {
969
988
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
970
989
  // `planAuth` builds, and its env reads what every other service's step
971
990
  // records or delivers, so in one apply it runs after all of them.
972
- const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon);
973
- if (archon.step)
991
+ const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon, blocked.archonDeployRunning);
992
+ // The host runs inside this app: its step ends by redeploying the process
993
+ // that runs the plan, so it goes after everything else (pushed below).
994
+ if (archon.step && !blocked.archonRunsInsideApp)
974
995
  plannedSteps.push(archon.step);
975
996
  // #257: after every service above, so a database any of them just
976
997
  // recorded is already visible to `recordedIds` on this same plan.
@@ -982,6 +1003,8 @@ export function planPlatform(platform, live, options = {}) {
982
1003
  const heartbeats = planHeartbeats(platform, recordedIds, hcReady, blocked.heartbeatsExecutorMissing ?? false, blocked.heartbeatsApiKeyMissing ?? false);
983
1004
  if (heartbeats.step)
984
1005
  plannedSteps.push(heartbeats.step);
1006
+ if (archon.step && blocked.archonRunsInsideApp)
1007
+ plannedSteps.push(archon.step);
985
1008
  // #251: one line per service whose recorded applied ref no longer matches
986
1009
  // what the next apply would use (or, for zitadel, what is actually live).
987
1010
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -2227,9 +2250,12 @@ function planAi(platform, recordedIds, executorBlocked) {
2227
2250
  * ({@link PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY}) plans the step again, as
2228
2251
  * {@link planAuth} does.
2229
2252
  */
2230
- function planArchon(platform, recordedIds, executorBlocked, authReady, drifted) {
2253
+ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted, deployRunning) {
2231
2254
  if (!platform.archon.enabled)
2232
2255
  return { step: null, blocked: null };
2256
+ // A deploy the step triggered is still running: planning it again would deploy twice.
2257
+ if (deployRunning)
2258
+ return { step: null, blocked: deployRunning };
2233
2259
  const key = resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY);
2234
2260
  const applied = Boolean(recordedIds[key]) &&
2235
2261
  Boolean(recordedIds[PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY]) &&
@@ -35,6 +35,8 @@ export declare const NO_DOMAIN_MAIL_APEX = "oikos.host";
35
35
  * names the same "positional arguments that shifted on every merge" problem
36
36
  * this avoids).
37
37
  */
38
+ /** The edge-routed ports an executor may be given its own implementation of. */
39
+ export type EdgePortName = 'dns' | 'zones' | 'access' | 'ratelimits' | 'tunnels';
38
40
  export interface OrgPlatformReadiness {
39
41
  /**
40
42
  * `null` until the platform's own mail server (#169) is enabled and
@@ -96,6 +98,22 @@ export interface OrgPlatformReadiness {
96
98
  * used to say there was none; N1 is no longer deployed by hand).
97
99
  */
98
100
  edgeReady: boolean;
101
+ /**
102
+ * #555: why a company step that writes through edge cannot run yet, `null`
103
+ * or absent when none is blocked. DNS records, zones, Access apps, rate
104
+ * limit rules and tunnels all go through edge unless the host supplies its
105
+ * own port ({@link ownEdgePorts}); while edge is not applied, `diff.ts`
106
+ * reports each such step blocked instead of planning one that would fail
107
+ * half way.
108
+ */
109
+ dnsBlocked?: string | null;
110
+ /**
111
+ * #555: the edge-routed ports the host's executor supplies itself
112
+ * (`FoundryStepExecutorPorts.dns` / `zones` / `access` / `ratelimits` /
113
+ * `tunnels`). The executor uses these before edge, so a step that needs
114
+ * only these is not blocked by {@link dnsBlocked}.
115
+ */
116
+ ownEdgePorts?: Partial<Record<EdgePortName, boolean>>;
99
117
  /** `https://<platform.edge.hostname>`, which an entry with `uses.edge` gets as `EDGE_URL`. `null` until `edgeReady`. */
100
118
  edgeUrl?: string | null;
101
119
  /**
@@ -149,6 +167,16 @@ export interface HeldKeyResolver {
149
167
  * (which could carry provider detail) goes nowhere.
150
168
  */
151
169
  export declare function heldPlatformKeyRefs(heldKeys: HeldKeyResolver): Promise<Set<string>>;
170
+ /**
171
+ * #555: the reason no company step may write DNS yet, `null` when one may.
172
+ * Every DNS record, zone, Access app, rate limit rule and tunnel call goes
173
+ * through edge unless the host supplies its own port
174
+ * ({@link OrgPlatformReadiness.ownEdgePorts}), so edge must be enabled and
175
+ * applied (the same check as `edgeReady`, without the host-port half). {@link FoundryPlanApply} sets
176
+ * {@link OrgPlatformReadiness.dnsBlocked} from this; the pure
177
+ * {@link orgPlatformReadinessFrom} leaves it unset (nothing blocked).
178
+ */
179
+ export declare function dnsBlockedReason(platform: DeclaredPlatform, platformRecordedIds: Readonly<Record<string, string>>): string | null;
152
180
  /**
153
181
  * `FoundryPlanApply.computeFreshPlan`'s own precedent for turning a
154
182
  * `PlatformStore`'s `get()`/`getServiceIds()` into what an org's plan needs.
@@ -63,6 +63,23 @@ export async function heldPlatformKeyRefs(heldKeys) {
63
63
  function coolifyAppApplied(platformRecordedIds, key) {
64
64
  return Boolean(platformRecordedIds[`coolify:app:${key}`]);
65
65
  }
66
+ /**
67
+ * #555: the reason no company step may write DNS yet, `null` when one may.
68
+ * Every DNS record, zone, Access app, rate limit rule and tunnel call goes
69
+ * through edge unless the host supplies its own port
70
+ * ({@link OrgPlatformReadiness.ownEdgePorts}), so edge must be enabled and
71
+ * applied (the same check as `edgeReady`, without the host-port half). {@link FoundryPlanApply} sets
72
+ * {@link OrgPlatformReadiness.dnsBlocked} from this; the pure
73
+ * {@link orgPlatformReadinessFrom} leaves it unset (nothing blocked).
74
+ */
75
+ export function dnsBlockedReason(platform, platformRecordedIds) {
76
+ if (!platform.edge.enabled) {
77
+ return 'every DNS record is written through edge, and the platform does not enable edge';
78
+ }
79
+ return coolifyAppApplied(platformRecordedIds, PLATFORM_EDGE_KEY)
80
+ ? null
81
+ : 'every DNS record is written through edge, and edge is not applied yet';
82
+ }
66
83
  /**
67
84
  * `FoundryPlanApply.computeFreshPlan`'s own precedent for turning a
68
85
  * `PlatformStore`'s `get()`/`getServiceIds()` into what an org's plan needs.
@@ -290,6 +290,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
290
290
  hc?: string;
291
291
  heartbeatsExecutorMissing?: boolean;
292
292
  heartbeatsApiKeyMissing?: boolean;
293
+ archonRunsInsideApp?: boolean;
294
+ archonDeployRunning?: string;
293
295
  }>;
294
296
  /**
295
297
  * Whether Archon's live Access policy lets through anyone but the declared
@@ -328,6 +330,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
328
330
  private executeSfu;
329
331
  private coolifyJobs;
330
332
  }
333
+ export declare const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = "platform-auth-login";
334
+ export declare const AUTH_LOGIN_PORT = "3000";
331
335
  /**
332
336
  * Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
333
337
  * own explicit list, restated here as one regex since the tunnel routes by a
@@ -474,7 +478,7 @@ export interface PlatformMailRuntime {
474
478
  * {@link PlatformStepExecutionContext}, bundled like {@link PlatformAuthDeps}.
475
479
  */
476
480
  export interface PlatformArchonDeps {
477
- coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait'>;
481
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment'>;
478
482
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
479
483
  dns: PlatformDnsFactory;
480
484
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -493,6 +497,15 @@ export interface PlatformArchonDeps {
493
497
  * Access app nobody can pass.
494
498
  */
495
499
  accessEmails: readonly string[];
500
+ /**
501
+ * True when the host process runs inside the Coolify app this step
502
+ * redeploys (Archon's console is `platform:archon`). A deploy that waits
503
+ * would be killed with the old container, so the step then records every
504
+ * applied marker first, and triggers the deploy last, without waiting.
505
+ * The planner puts the step last in the plan. Next plan: the step runs
506
+ * again only if the app is not running (`archonRedeployFailed`).
507
+ */
508
+ runsInsideApp?: boolean;
496
509
  /**
497
510
  * Reads a secret an earlier step handed to the host through
498
511
  * `deliverSecret`, or `null` when none is held under `ref` yet -- the same
@@ -8,7 +8,7 @@ import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment,
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
9
  import { healthCheckSettings } from './health-check-settings.js';
10
10
  import { coolifyDomains, filesShareHostname } from './hostnames.js';
11
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
11
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
12
12
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
13
13
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
14
14
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -318,6 +318,18 @@ export class FoundryPlatformStepExecutor {
318
318
  if (!apiKey)
319
319
  blocked.heartbeatsApiKeyMissing = true;
320
320
  }
321
+ // Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
322
+ if (this.archon?.runsInsideApp) {
323
+ blocked.archonRunsInsideApp = true;
324
+ // A deploy the step triggered and did not wait for: while it runs, a
325
+ // second deploy must not be planned.
326
+ const deployment = recordedIds[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY];
327
+ if (platform.archon.enabled &&
328
+ deployment &&
329
+ (await archonDeploymentState(this.archon.coolify, deployment)) === 'running') {
330
+ blocked.archonDeployRunning = `Archon is blocked, not planned: its deployment ${deployment} is still running; plan again when it has finished`;
331
+ }
332
+ }
321
333
  return blocked;
322
334
  }
323
335
  /**
@@ -347,7 +359,18 @@ export class FoundryPlatformStepExecutor {
347
359
  !brokerDrifted &&
348
360
  !bootstrapOperatorDrifted &&
349
361
  (await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
350
- if (accessDrifted || brokerDrifted || bootstrapOperatorDrifted || rollingUpdateDrifted)
362
+ // The deployment the step triggered failed or Coolify does not know it:
363
+ // plan the step again, which deploys again. A finished one is confirmed
364
+ // by the next run of the step (keys-synced), see `provisionPlatformArchon`.
365
+ const deployment = recordedIds[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY];
366
+ const redeployFailed = this.archon.runsInsideApp === true &&
367
+ Boolean(deployment) &&
368
+ (await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
369
+ if (accessDrifted ||
370
+ brokerDrifted ||
371
+ bootstrapOperatorDrifted ||
372
+ rollingUpdateDrifted ||
373
+ redeployFailed)
351
374
  result.archon = true;
352
375
  }
353
376
  if (platform.auth.enabled && this.auth) {
@@ -672,7 +695,7 @@ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
672
695
  * header and so cannot give two different apps the same public hostname.
673
696
  */
674
697
  const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
675
- const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
698
+ export const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
676
699
  const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
677
700
  const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
678
701
  /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
@@ -691,7 +714,7 @@ const AUTH_BROKER_DOCKERFILE = '/broker/Dockerfile';
691
714
  * own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
692
715
  */
693
716
  const AUTH_ZITADEL_PORT = '8080';
694
- const AUTH_LOGIN_PORT = '3000';
717
+ export const AUTH_LOGIN_PORT = '3000';
695
718
  /** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
696
719
  const AUTH_BROKER_PORT = '3000';
697
720
  /**
@@ -2600,6 +2623,7 @@ async function provisionPlatformArchon(context, deps) {
2600
2623
  const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
2601
2624
  const runtime = deps.runtime ?? {};
2602
2625
  const sleep = runtime.sleep ?? defaultSleep;
2626
+ const selfRedeploy = deps.runsInsideApp === true;
2603
2627
  if (deps.accessEmails.length === 0) {
2604
2628
  throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
2605
2629
  }
@@ -3030,7 +3054,17 @@ async function provisionPlatformArchon(context, deps) {
3030
3054
  // patch above actually takes effect on the running container, not just on
3031
3055
  // Coolify's stored config.
3032
3056
  const rollingUpdateApplied = recorded[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1';
3033
- const archonDeployedNow = keyUnsynced ||
3057
+ // The deployment an earlier run triggered without waiting: read before
3058
+ // deciding whether to deploy again.
3059
+ const priorDeployment = selfRedeploy ? recorded[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY] : undefined;
3060
+ const deployState = priorDeployment
3061
+ ? await archonDeploymentState(deps.coolify, priorDeployment)
3062
+ : 'none';
3063
+ if (deployState === 'running' || deployState === 'unreadable') {
3064
+ throw new Error(`platform:archon: the deployment ${priorDeployment} cannot be confirmed yet (${deployState === 'running' ? 'still running' : 'status unreadable'}); plan again later`);
3065
+ }
3066
+ const archonDeployedNow = (keyUnsynced && deployState !== 'finished') ||
3067
+ deployState === 'failed' ||
3034
3068
  app.justCreated ||
3035
3069
  !app.running ||
3036
3070
  changed ||
@@ -3039,7 +3073,7 @@ async function provisionPlatformArchon(context, deps) {
3039
3073
  appliedGitRef !== gitRef ||
3040
3074
  !rollingUpdateApplied ||
3041
3075
  recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname;
3042
- if (archonDeployedNow) {
3076
+ if (archonDeployedNow && !selfRedeploy) {
3043
3077
  // #467: cleared before the deploy below is even attempted, not after --
3044
3078
  // a deploy that then fails, or a crash before `waitForHealthyVersion`
3045
3079
  // passes, must leave this run's attempt unconfirmed. Recording it only
@@ -3111,13 +3145,14 @@ async function provisionPlatformArchon(context, deps) {
3111
3145
  // Coolify's own deployment record finished, which is what actually closes
3112
3146
  // the loophole named in #252 (an old container staying healthy while the
3113
3147
  // deployment itself fails).
3114
- if (archonDeployedNow) {
3148
+ if (archonDeployedNow && !selfRedeploy) {
3115
3149
  await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
3116
3150
  }
3117
3151
  // #398: the deployment finished and answers health, so Archon now runs with
3118
3152
  // the key id its env carries. `provisionPlatformIntegrations` drops the
3119
3153
  // previous public key only on this marker.
3120
- if (keyUnsynced && integrationsKeyId) {
3154
+ // With `runsInsideApp` only once Coolify's deployment record says it finished.
3155
+ if (keyUnsynced && integrationsKeyId && (!selfRedeploy || deployState === 'finished')) {
3121
3156
  await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
3122
3157
  }
3123
3158
  // #475: confirmed only now, after the health check above has passed on a
@@ -3140,6 +3175,36 @@ async function provisionPlatformArchon(context, deps) {
3140
3175
  // it.
3141
3176
  await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
3142
3177
  await record(PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, bootstrapOperator ?? 'none');
3178
+ // The host runs inside this app: every marker is recorded above, so the
3179
+ // deploy goes last and is not waited for -- the old container stops during
3180
+ // it, and the response, the lease and the audit row must be done first.
3181
+ if (selfRedeploy && !archonDeployedNow && deployState === 'finished') {
3182
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, ''); // confirmed above
3183
+ }
3184
+ if (selfRedeploy && archonDeployedNow) {
3185
+ await record(`coolify:app:${PLATFORM_ARCHON_KEY}`, appId);
3186
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, 'pending');
3187
+ let deploymentUuid;
3188
+ try {
3189
+ // `force` takes the branch that always creates a real deployment record
3190
+ // (a skipped one answers with a uuid Coolify never stored).
3191
+ const started = (await deps.coolify.deploy(appId, true));
3192
+ const uuid = Array.isArray(started) ? started[0]?.deployment_uuid : undefined;
3193
+ if (typeof uuid !== 'string' || !uuid) {
3194
+ throw new Error('platform:archon: Coolify answered the deploy with no deployment_uuid');
3195
+ }
3196
+ deploymentUuid = uuid;
3197
+ }
3198
+ catch (error) {
3199
+ // Not triggered: forget the applied ref so the next plan runs the step again.
3200
+ await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, '');
3201
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, '');
3202
+ throw error;
3203
+ }
3204
+ // The next plan reads this deployment's status; a kill before this write
3205
+ // leaves `pending`, which that plan reads as unconfirmed and plans again.
3206
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, deploymentUuid);
3207
+ }
3143
3208
  return { resourceId: appId };
3144
3209
  }
3145
3210
  // #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
@@ -3560,6 +3625,28 @@ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3560
3625
  * version of this check never actually fired. `custom_network_aliases` and
3561
3626
  * `health_check_enabled` are both top-level there.
3562
3627
  */
3628
+ /**
3629
+ * The state of the deployment Archon's step triggered without waiting
3630
+ * (`PlatformArchonDeps.runsInsideApp`), from Coolify's own deployment record:
3631
+ * `running` (queued or in progress), `finished`, `failed` (failed, cancelled,
3632
+ * its uuid never recorded, or unknown to Coolify -- a 404), or `unreadable`
3633
+ * for any other error. Status and a served request cannot tell a failed build
3634
+ * from a success (the old container keeps serving), so this record is the
3635
+ * only honest answer.
3636
+ */
3637
+ async function archonDeploymentState(coolify, deploymentUuid) {
3638
+ if (deploymentUuid === 'pending')
3639
+ return 'failed';
3640
+ try {
3641
+ const { status } = await coolify.getDeployment(deploymentUuid);
3642
+ if (status === 'queued' || status === 'in_progress')
3643
+ return 'running';
3644
+ return status === 'finished' ? 'finished' : 'failed';
3645
+ }
3646
+ catch (error) {
3647
+ return isNotFoundError(error) ? 'failed' : 'unreadable';
3648
+ }
3649
+ }
3563
3650
  async function archonRollingUpdateDrifted(coolify, appId) {
3564
3651
  if (!appId)
3565
3652
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.21.1",
3
+ "version": "0.22.1",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,7 @@
37
37
  "calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs"
38
38
  },
39
39
  "peerDependencies": {
40
+ "@wtfalch/providers": ">=0.7.4",
40
41
  "drizzle-orm": ">=0.45.2"
41
42
  },
42
43
  "dependencies": {
@@ -44,7 +45,7 @@
44
45
  "@wtfalch/auth": "0.16.0",
45
46
  "@wtfalch/edge": "0.9.0",
46
47
  "@wtfalch/email": "^0.17.0",
47
- "@wtfalch/providers": "0.7.3",
48
+ "@wtfalch/providers": "0.7.5",
48
49
  "ssh2": "1.17.0",
49
50
  "zod": "4.6.5"
50
51
  },