@wtfalch/foundry 0.22.1 → 0.22.2

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
@@ -22,7 +22,7 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
23
  export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
24
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
25
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
25
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
26
26
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
27
27
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
28
28
  export type { PlatformOrgClientFactories } from './plan/index.js';
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
14
  export { AdoptRefusedError } from './plan/index.js';
15
15
  export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
16
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
16
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
17
17
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
18
18
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
19
19
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
package/dist/plan/diff.js CHANGED
@@ -6,7 +6,7 @@ import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv,
6
6
  import { planEntryAiKeys } from './entry-ai-key.js';
7
7
  import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
8
8
  import { planFilesKeys } from './entry-files-key.js';
9
- import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
9
+ import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelDomainList } from './hostnames.js';
10
10
  import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
11
11
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
12
12
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
@@ -326,7 +326,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
326
326
  // #173: register the org's own app with the platform's jobs scheduler.
327
327
  // After Agora, so a failing registration can never stop Agora from
328
328
  // provisioning (`runSteps` stops at the first failing step).
329
- const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
329
+ const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
330
330
  if (jobsRegistration.step)
331
331
  plannedSteps.push(jobsRegistration.step);
332
332
  // F8 (#318), after Agora and Boule, not right after the zone: a route
@@ -2186,7 +2186,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2186
2186
  }
2187
2187
  // A changed hostname reaches an app that is already live: its domains are
2188
2188
  // patched and it is redeployed.
2189
- if (JSON.stringify(entryHostnames(org, entry)) !==
2189
+ if (JSON.stringify(tunnelDomainList(entryHostnames(org, entry))) !==
2190
2190
  JSON.stringify(appliedAppDomains(org, recordedIds, entryKey))) {
2191
2191
  return {
2192
2192
  step: {
@@ -2344,7 +2344,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
2344
2344
  * {@link planSite}'s own `hostname` local does: `DeclaredOrg.domain` once
2345
2345
  * `hosting.usesDomain` is true, `<slug>.oikos.host` otherwise.
2346
2346
  */
2347
- function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = null, noKeyReader = false) {
2347
+ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = null, noKeyReader = false, addressMissing = false) {
2348
2348
  if (!org.hosting.enabled)
2349
2349
  return { step: null, blocked: null };
2350
2350
  const key = resourceKey('jobs', 'registration', org.id);
@@ -2366,11 +2366,13 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
2366
2366
  if (!jobsAvailable) {
2367
2367
  return {
2368
2368
  step: null,
2369
- blocked: !missingKeyRef
2370
- ? `${org.slug}'s jobs registration is blocked, not planned: the host supplies no jobs scheduler`
2371
- : `${org.slug}'s jobs registration is blocked, not planned: ${noKeyReader
2372
- ? `the host gave no held-keys reader and no jobs scheduler port, so the platform's jobs admin token ${missingKeyRef} cannot be checked`
2373
- : `the platform's jobs admin token ${missingKeyRef} is not held yet`}`,
2369
+ blocked: addressMissing
2370
+ ? `${org.slug}'s jobs registration is blocked, not planned: the platform's jobs scheduler has not recorded its internal address yet; apply the platform's jobs step first`
2371
+ : !missingKeyRef
2372
+ ? `${org.slug}'s jobs registration is blocked, not planned: the host supplies no jobs scheduler`
2373
+ : `${org.slug}'s jobs registration is blocked, not planned: ${noKeyReader
2374
+ ? `the host gave no held-keys reader and no jobs scheduler port, so the platform's jobs admin token ${missingKeyRef} cannot be checked`
2375
+ : `the platform's jobs admin token ${missingKeyRef} is not held yet`}`,
2374
2376
  };
2375
2377
  }
2376
2378
  const hostname = appHostname(org);
@@ -20,7 +20,7 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
20
20
  import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
21
21
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
22
22
  import { healthCheckSettings } from './health-check-settings.js';
23
- import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
23
+ import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
24
24
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
25
25
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
26
26
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
@@ -1787,11 +1787,12 @@ export class FoundryStepExecutor {
1787
1787
  // An entry with no names is internal: the other entries reach it as
1788
1788
  // `<slug>-<key>`, and it gets no domain, route or DNS record.
1789
1789
  const names = entryHostnames(org, entry);
1790
+ const wantDomains = tunnelDomainList(names);
1790
1791
  const isStatic = entry.buildPack === 'static';
1791
1792
  const folderField = isStatic ? 'publish_directory' : 'base_directory';
1792
1793
  const settingsKey = appSettingsAppliedScopeKey(entry.key);
1793
1794
  const domainsKey = appDomainsAppliedScopeKey(entry.key);
1794
- let haveNames;
1795
+ let haveDomains;
1795
1796
  let have;
1796
1797
  let appId;
1797
1798
  // A stopped app with no settings marker was never deployed successfully
@@ -1812,7 +1813,7 @@ export class FoundryStepExecutor {
1812
1813
  neverDeployed = context.recordedIds()[settingsKey] === undefined;
1813
1814
  }
1814
1815
  have = appliedAppSettings(context.recordedIds(), entry.key);
1815
- haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
1816
+ haveDomains = appliedAppDomains(org, context.recordedIds(), entry.key);
1816
1817
  }
1817
1818
  else {
1818
1819
  const server = await this.resolveServer(org, context);
@@ -1827,7 +1828,7 @@ export class FoundryStepExecutor {
1827
1828
  git_branch: entry.branch,
1828
1829
  build_pack: entry.buildPack,
1829
1830
  name: appNameFor(org, entry),
1830
- domains: names.length > 0 ? coolifyDomains(names) : undefined,
1831
+ domains: names.length > 0 ? coolifyTunnelDomains(names) : undefined,
1831
1832
  autogenerate_domain: names.length > 0 ? undefined : false,
1832
1833
  // A static site is served by Coolify's own web server on port 80.
1833
1834
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
@@ -1840,7 +1841,7 @@ export class FoundryStepExecutor {
1840
1841
  appId = created.uuid;
1841
1842
  // Create sent every setting, so nothing is left to patch.
1842
1843
  have = appSettingsOf(entry);
1843
- haveNames = names;
1844
+ haveDomains = wantDomains;
1844
1845
  }
1845
1846
  // Patch what differs from what the app has. A crash before the marker is
1846
1847
  // recorded below leaves the old one, so the retry patches the same
@@ -1869,10 +1870,12 @@ export class FoundryStepExecutor {
1869
1870
  Object.assign(patch, healthCheckSettings(entry.healthPath ?? (isStatic ? '/' : '/api/health'), healthCheckPort));
1870
1871
  }
1871
1872
  // Create names the app after `domains`; a changed name reaches a live app
1872
- // here (rule 3). The internal name is set after create, as the calls
1873
- // stack's apps do.
1874
- if (JSON.stringify(names) !== JSON.stringify(haveNames))
1875
- patch.domains = coolifyDomains(names);
1873
+ // here (rule 3), and so does an app made with the `https://` form, whose
1874
+ // redirect loops behind the tunnel. The internal name is set after
1875
+ // create, as the calls stack's apps do.
1876
+ if (JSON.stringify(wantDomains) !== JSON.stringify(haveDomains)) {
1877
+ patch.domains = coolifyTunnelDomains(names);
1878
+ }
1876
1879
  if (names.length === 0 && (!existing || patch.domains !== undefined)) {
1877
1880
  patch.custom_internal_name = appNameFor(org, entry);
1878
1881
  }
@@ -1969,7 +1972,7 @@ export class FoundryStepExecutor {
1969
1972
  // A port taken back to null was not patched, so the marker keeps the old
1970
1973
  // one and `planApp` reports it instead of planning it.
1971
1974
  context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
1972
- context.recordScopeId(domainsKey, JSON.stringify(names));
1975
+ context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
1973
1976
  if (wantLabelsHash)
1974
1977
  context.recordScopeId(labelsKey, wantLabelsHash);
1975
1978
  context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
@@ -3043,7 +3046,7 @@ export class FoundryStepExecutor {
3043
3046
  git_branch: 'main',
3044
3047
  build_pack: 'dockerfile',
3045
3048
  name: input.name,
3046
- domains: coolifyDomains([input.domain]),
3049
+ domains: coolifyTunnelDomains([input.domain]),
3047
3050
  dockerfile_location: input.dockerfileLocation,
3048
3051
  });
3049
3052
  return { appId: created.uuid, existed: false };
@@ -3739,7 +3742,7 @@ export class FoundryStepExecutor {
3739
3742
  git_branch: 'main',
3740
3743
  build_pack: 'dockerfile',
3741
3744
  name,
3742
- domains: coolifyDomains([callsHostnameFor(org)]),
3745
+ domains: coolifyTunnelDomains([callsHostnameFor(org)]),
3743
3746
  dockerfile_location: CALLS_SERVICE_DOCKERFILE,
3744
3747
  }));
3745
3748
  // A fixed internal name, like Synapse and MAS above, so the org's Agora
@@ -4022,7 +4025,7 @@ export class FoundryStepExecutor {
4022
4025
  git_branch: 'main',
4023
4026
  build_pack: 'dockerfile',
4024
4027
  name,
4025
- domains: coolifyDomains([agoraHostnameFor(org)]),
4028
+ domains: coolifyTunnelDomains([agoraHostnameFor(org)]),
4026
4029
  dockerfile_location: AGORA_APP_DOCKERFILE,
4027
4030
  ports_exposes: AGORA_APP_PORTS_EXPOSES,
4028
4031
  }));
@@ -4132,7 +4135,7 @@ export class FoundryStepExecutor {
4132
4135
  git_branch: 'main',
4133
4136
  build_pack: 'dockerfile',
4134
4137
  name,
4135
- domains: coolifyDomains([bouleHostnameFor(org)]),
4138
+ domains: coolifyTunnelDomains([bouleHostnameFor(org)]),
4136
4139
  dockerfile_location: BOULE_APP_DOCKERFILE,
4137
4140
  }));
4138
4141
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
@@ -20,10 +20,22 @@ export declare function wildcardHostname(org: DeclaredOrg): string | null;
20
20
  export declare function appHostname(org: DeclaredOrg): string;
21
21
  /**
22
22
  * Coolify's `domains` field: comma-separated full URLs. A bare hostname is a
23
- * 422 ("Invalid URL"), so every write names each host as `https://<host>`.
23
+ * 422 ("Invalid URL"), so every write names each host as a URL. This helper
24
+ * writes `https://<host>` for the platform's apps; a company's apps are reached
25
+ * through `coolify-proxy:80` and use {@link coolifyTunnelDomains} (`http://`).
24
26
  * Foundry's own records and proxy labels keep the bare names.
25
27
  */
26
28
  export declare function coolifyDomains(names: readonly string[]): string;
29
+ /**
30
+ * The `domains` entries of a company's app, one URL per host. Every company
31
+ * tunnel delivers plain http to Traefik (`COOLIFY_PROXY_ORIGIN`), and Coolify
32
+ * puts a redirect-to-https on the http entry point of an `https://` domain,
33
+ * so a request through the tunnel would be redirected to itself forever. The
34
+ * `http://` form is served as it is; Cloudflare terminates TLS in front.
35
+ */
36
+ export declare function tunnelDomainList(names: readonly string[]): string[];
37
+ /** {@link tunnelDomainList} as Coolify's comma-separated `domains` field. */
38
+ export declare function coolifyTunnelDomains(names: readonly string[]): string;
27
39
  /** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
28
40
  export declare function legacyAppHostname(org: DeclaredOrg): string;
29
41
  /**
@@ -37,12 +37,28 @@ export function appHostname(org) {
37
37
  }
38
38
  /**
39
39
  * Coolify's `domains` field: comma-separated full URLs. A bare hostname is a
40
- * 422 ("Invalid URL"), so every write names each host as `https://<host>`.
40
+ * 422 ("Invalid URL"), so every write names each host as a URL. This helper
41
+ * writes `https://<host>` for the platform's apps; a company's apps are reached
42
+ * through `coolify-proxy:80` and use {@link coolifyTunnelDomains} (`http://`).
41
43
  * Foundry's own records and proxy labels keep the bare names.
42
44
  */
43
45
  export function coolifyDomains(names) {
44
46
  return names.map((name) => `https://${name}`).join(',');
45
47
  }
48
+ /**
49
+ * The `domains` entries of a company's app, one URL per host. Every company
50
+ * tunnel delivers plain http to Traefik (`COOLIFY_PROXY_ORIGIN`), and Coolify
51
+ * puts a redirect-to-https on the http entry point of an `https://` domain,
52
+ * so a request through the tunnel would be redirected to itself forever. The
53
+ * `http://` form is served as it is; Cloudflare terminates TLS in front.
54
+ */
55
+ export function tunnelDomainList(names) {
56
+ return names.map((name) => `http://${name}`);
57
+ }
58
+ /** {@link tunnelDomainList} as Coolify's comma-separated `domains` field. */
59
+ export function coolifyTunnelDomains(names) {
60
+ return tunnelDomainList(names).join(',');
61
+ }
46
62
  /** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
47
63
  export function legacyAppHostname(org) {
48
64
  return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -1,7 +1,7 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { stepSystemOf } from './step-system.js';
4
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
4
+ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
@@ -490,15 +490,19 @@ export declare function appSettingsAppliedScopeKey(entryKey: string): string;
490
490
  /** The settings the app has: the marker, or the defaults when there is none. */
491
491
  export declare function appliedAppSettings(recorded: Readonly<Record<string, string>>, entryKey: string): AppSettings;
492
492
  /**
493
- * The names `coolifyApp` last put in the entry's Coolify `domains`, as a JSON
494
- * array (`[]` for an internal entry). `planApp` plans the step again, with a
495
- * redeploy, when {@link entryHostnames} differs from it.
493
+ * The URLs `coolifyApp` last put in the entry's Coolify `domains`
494
+ * (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
495
+ * plans the step again, with a redeploy, when the wanted URLs differ from it.
496
+ * A marker of bare names, written before the `http://` form, never equals
497
+ * them, so an app made with the `https://` form is patched once.
496
498
  */
497
499
  export declare function appDomainsAppliedScopeKey(entryKey: string): string;
498
500
  /**
499
- * The names the app has: the marker, or for an app that predates it the one
500
- * name it was created with (the `app` entry's address, nothing for any other
501
- * entry), so an app that runs today plans no change.
501
+ * The domains the app has: the marker, or for an app with no marker the one
502
+ * bare name it was created with (the `app` entry's address, nothing for any
503
+ * other entry). A bare name is never a URL `tunnelDomainList` writes, so an
504
+ * app with no marker is patched once: the scheme it was created with is
505
+ * unknown, bare before 4b2380f and `https://` after it.
502
506
  */
503
507
  export declare function appliedAppDomains(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, entryKey: string): string[];
504
508
  /** Whether the secret sources can be filled right now: the mail key is held, and a new shared secret can be held. */
@@ -935,6 +939,13 @@ export declare function planPlatform(platform: DeclaredPlatform, live: LiveResou
935
939
  * it, so the planner and the executor/reader can never key it apart).
936
940
  */
937
941
  export declare const JOBS_SCHEDULER_KEY = "jobs";
942
+ /**
943
+ * The scheduler's address on the Coolify network (`http://<custom_internal_name>:8080`),
944
+ * the value the org steps' jobs port and Archon's `JOBS_URL` are read from.
945
+ * Not a secret. The scheduler's public hostname has no tunnel and no DNS
946
+ * record, so nothing on the platform can reach it there.
947
+ */
948
+ export declare const JOBS_INTERNAL_URL_SCOPE_KEY = "jobs_internal_url";
938
949
  /**
939
950
  * #257: the six platform databases foundry itself creates, each keyed by
940
951
  * the scope id `platform-providers.ts`'s own `createPostgresDatabase` call
@@ -533,17 +533,21 @@ export function appliedAppSettings(recorded, entryKey) {
533
533
  return marker ? JSON.parse(marker) : DEFAULT_APP_SETTINGS;
534
534
  }
535
535
  /**
536
- * The names `coolifyApp` last put in the entry's Coolify `domains`, as a JSON
537
- * array (`[]` for an internal entry). `planApp` plans the step again, with a
538
- * redeploy, when {@link entryHostnames} differs from it.
536
+ * The URLs `coolifyApp` last put in the entry's Coolify `domains`
537
+ * (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
538
+ * plans the step again, with a redeploy, when the wanted URLs differ from it.
539
+ * A marker of bare names, written before the `http://` form, never equals
540
+ * them, so an app made with the `https://` form is patched once.
539
541
  */
540
542
  export function appDomainsAppliedScopeKey(entryKey) {
541
543
  return `app_domains_applied.${entryKey}`;
542
544
  }
543
545
  /**
544
- * The names the app has: the marker, or for an app that predates it the one
545
- * name it was created with (the `app` entry's address, nothing for any other
546
- * entry), so an app that runs today plans no change.
546
+ * The domains the app has: the marker, or for an app with no marker the one
547
+ * bare name it was created with (the `app` entry's address, nothing for any
548
+ * other entry). A bare name is never a URL `tunnelDomainList` writes, so an
549
+ * app with no marker is patched once: the scheme it was created with is
550
+ * unknown, bare before 4b2380f and `https://` after it.
547
551
  */
548
552
  export function appliedAppDomains(org, recorded, entryKey) {
549
553
  const marker = recorded[appDomainsAppliedScopeKey(entryKey)];
@@ -1129,6 +1133,13 @@ function planApexZone(platform, byKey, claimed) {
1129
1133
  * it, so the planner and the executor/reader can never key it apart).
1130
1134
  */
1131
1135
  export const JOBS_SCHEDULER_KEY = 'jobs';
1136
+ /**
1137
+ * The scheduler's address on the Coolify network (`http://<custom_internal_name>:8080`),
1138
+ * the value the org steps' jobs port and Archon's `JOBS_URL` are read from.
1139
+ * Not a secret. The scheduler's public hostname has no tunnel and no DNS
1140
+ * record, so nothing on the platform can reach it there.
1141
+ */
1142
+ export const JOBS_INTERNAL_URL_SCOPE_KEY = 'jobs_internal_url';
1132
1143
  /**
1133
1144
  * #173: the jobs scheduler (https://github.com/wtfalch/jobs) itself, as a
1134
1145
  * platform-level Coolify application with its own database -- provision-only,
@@ -1159,8 +1170,11 @@ function planJobs(platform, byKey, claimed, observable, recordedIds) {
1159
1170
  // So does a recorded pending deploy: the step wrote its env and died
1160
1171
  // before the deploy finished.
1161
1172
  const databaseStatus = live.config.databaseStatus;
1173
+ // So does a missing internal address: a scheduler applied before the
1174
+ // address was recorded gets it from the next apply.
1162
1175
  if (live.config.ownerUrlMissing !== true &&
1163
1176
  !recordedIds[JOBS_DEPLOY_PENDING_SCOPE_KEY] &&
1177
+ recordedIds[JOBS_INTERNAL_URL_SCOPE_KEY] &&
1164
1178
  (typeof databaseStatus !== 'string' || databaseStatus.startsWith('running'))) {
1165
1179
  return { step: null, blocked: null };
1166
1180
  }
@@ -1,5 +1,5 @@
1
1
  import { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
2
- import { PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
2
+ import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
3
3
  import { FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
4
4
  /**
5
5
  * #177: what `platform-org.ts`'s readiness could not see between a plan and
@@ -44,8 +44,9 @@ export class PlatformOrgPorts {
44
44
  if (!platform?.jobs.enabled) {
45
45
  throw new PlatformOrgBlockedError('jobs', 'the platform does not declare a jobs scheduler');
46
46
  }
47
+ const baseUrl = await this.recorded('jobs', JOBS_INTERNAL_URL_SCOPE_KEY);
47
48
  const token = await this.held('jobs', JOBS_ADMIN_TOKEN_REF);
48
- return new HttpJobsSchedulerClient(`https://${platform.jobs.hostname}`, token, this.options.fetch);
49
+ return new HttpJobsSchedulerClient(baseUrl, token, this.options.fetch);
49
50
  }
50
51
  async authHostname() {
51
52
  return (await this.options.store.get())?.auth.hostname ?? null;
@@ -87,8 +87,14 @@ export interface OrgPlatformReadiness {
87
87
  callsReady: boolean;
88
88
  /** The platform's own sign-in (#168), which every org's own ZITADEL org lives inside. */
89
89
  authReady: boolean;
90
- /** The jobs scheduler (#173) is enabled, applied, and its admin token is held. */
90
+ /** The jobs scheduler (#173) is enabled, applied, has recorded its internal address, and its admin token is held. */
91
91
  jobsReady: boolean;
92
+ /**
93
+ * The scheduler is applied but has recorded no internal address, so the
94
+ * org's registration cannot reach it; `diff.ts` reports the registration
95
+ * blocked with that reason. Absent when false.
96
+ */
97
+ jobsUrlMissing?: boolean;
92
98
  /**
93
99
  * #317 (F7) and #478: the edge service is declared `enabled`, its own
94
100
  * `coolify:app` deploy step has applied (`provisionPlatformEdge`), and
@@ -1,4 +1,4 @@
1
- import { JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
1
+ import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
2
2
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
3
3
  /**
4
4
  * #443: the no-domain company mail apex -- `<slug>.oikos.host`, the same
@@ -103,6 +103,7 @@ edgePortPresent = false) {
103
103
  const filesApplied = platform.files.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_FILES_KEY);
104
104
  const aiApplied = platform.ai.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AI_KEY);
105
105
  const jobsApplied = platform.jobs.enabled && Boolean(platformRecordedIds[`coolify:jobs:${JOBS_SCHEDULER_KEY}`]);
106
+ const jobsUrl = platformRecordedIds[JOBS_INTERNAL_URL_SCOPE_KEY];
106
107
  const edgeApplied = platform.edge.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_EDGE_KEY);
107
108
  const missing = (applied, ref) => applied && heldRefs !== undefined && !heldRefs.has(ref) ? ref : null;
108
109
  const missingKeys = {
@@ -129,7 +130,8 @@ edgePortPresent = false) {
129
130
  aiUrl: aiApplied ? (platformRecordedIds[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] ?? null) : null,
130
131
  callsReady: platform.sfu.enabled,
131
132
  authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
132
- jobsReady: jobsApplied && missingKeys.jobs === null,
133
+ jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
134
+ ...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
133
135
  edgeReady: edgeApplied && edgePortPresent,
134
136
  edgeUrl: edgeApplied && edgePortPresent ? `https://${platform.edge.hostname}` : null,
135
137
  missingKeys,
@@ -2,6 +2,7 @@ import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
2
  import type { DeclaredOrg } from '../org.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { DeclaredOrgStore, PlatformStore } from '../store.js';
5
+ import { type DeployHealthRuntime } from './deploy-health.js';
5
6
  import type { StepAction } from './diff.js';
6
7
  import type { AccessPort } from './edge-access.js';
7
8
  import type { DnsPort } from './edge-dns.js';
@@ -117,7 +118,7 @@ export declare const JOBS_ADMIN_TOKEN_REF = "jobs:scheduler:adminToken";
117
118
  * org's own app, reused here for the platform's one scheduler instead of once
118
119
  * per org.
119
120
  */
120
- type JobsCoolifyPort = Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'requireGithubApp' | 'findApplicationByName' | 'createGithubAppApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'getApplication' | 'deploy'>;
121
+ type JobsCoolifyPort = Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'requireGithubApp' | 'findApplicationByName' | 'createGithubAppApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'getApplication' | 'updateApplication' | 'deployAndWait'>;
121
122
  /**
122
123
  * Options for {@link FoundryPlatformStepExecutor}'s constructor, replacing
123
124
  * four positional arguments that shifted on every merge that added a
@@ -133,6 +134,8 @@ export interface FoundryPlatformStepExecutorOptions {
133
134
  * an error). Needed by `coolify:jobs` for the scheduler's admin token.
134
135
  */
135
136
  deliverSecret?: (ref: string, secret: string) => Promise<void>;
137
+ /** Clock, sleep and deployment wait for `coolify:jobs`; real ones by default. */
138
+ runtime?: DeployHealthRuntime;
136
139
  sfu?: CallsSfuPlatformDeps;
137
140
  /**
138
141
  * #168: dependencies for `coolify:app` keyed {@link PLATFORM_AUTH_KEY} --
@@ -264,6 +267,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
264
267
  private readonly declaredOrgStore;
265
268
  private readonly coolify?;
266
269
  private readonly deliverSecret?;
270
+ private readonly runtime;
267
271
  private readonly sfu?;
268
272
  private readonly auth?;
269
273
  private readonly mail?;
@@ -7,8 +7,8 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
7
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
9
  import { healthCheckSettings } from './health-check-settings.js';
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_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';
10
+ import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
11
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_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`). */
@@ -71,6 +71,9 @@ export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
71
71
  const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
72
72
  /** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
73
73
  const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
74
+ /** The scheduler's fixed `custom_internal_name` and the port it listens on (jobs' `PORT`, default 8080). */
75
+ const JOBS_INTERNAL_NAME = 'platform-jobs';
76
+ const JOBS_PORT = '8080';
74
77
  /**
75
78
  * The real {@link PlatformStepExecutor} for the two steps built so far:
76
79
  * `cloudflare:zone` for the platform's apex (#167) and `coolify:jobs` for the
@@ -140,6 +143,7 @@ export class FoundryPlatformStepExecutor {
140
143
  declaredOrgStore;
141
144
  coolify;
142
145
  deliverSecret;
146
+ runtime;
143
147
  sfu;
144
148
  auth;
145
149
  mail;
@@ -156,6 +160,7 @@ export class FoundryPlatformStepExecutor {
156
160
  this.declaredOrgStore = declaredOrgStore;
157
161
  this.coolify = options.coolify;
158
162
  this.deliverSecret = options.deliverSecret;
163
+ this.runtime = options.runtime ?? {};
159
164
  this.sfu = options.sfu;
160
165
  this.auth = options.auth;
161
166
  this.mail = options.mail;
@@ -615,6 +620,12 @@ export class FoundryPlatformStepExecutor {
615
620
  });
616
621
  appId = created.uuid;
617
622
  }
623
+ // Set on every run, like `platform:files`' runner: the name another
624
+ // container reaches the scheduler by. It only takes effect on a deploy.
625
+ await coolify.updateApplication(appId, {
626
+ custom_internal_name: JOBS_INTERNAL_NAME,
627
+ ports_exposes: JOBS_PORT,
628
+ });
618
629
  const db = await getDatabaseStarted(coolify, dbId);
619
630
  if (!db.internal_db_url) {
620
631
  throw new Error(`platform step coolify:jobs: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
@@ -670,12 +681,23 @@ export class FoundryPlatformStepExecutor {
670
681
  // a redeploy, so deploy after a create or a new token. Also when it is not
671
682
  // running, so a deploy that failed on an earlier run is retried.
672
683
  const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
673
- if (!existingApp || tokenJustSet || pending || !running)
674
- await coolify.deploy(appId);
684
+ // An app applied before its internal address was recorded has no fixed
685
+ // name yet, so it deploys once to get it; after that it deploys on the
686
+ // other conditions only.
687
+ const addressNew = !context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
688
+ // The address is recorded only once the deployment finished: the scheduler
689
+ // has no route that answers without its admin token (every other path is a
690
+ // 401, 404 or 405), so the deployment record is the whole check, as for
691
+ // the AI worker. A deploy that fails or times out throws here, records
692
+ // nothing and leaves the step planned.
693
+ if (!existingApp || tokenJustSet || pending || !running || addressNew) {
694
+ await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, this.runtime, this.runtime.sleep ?? defaultSleep, 'platform step coolify:jobs', 'the scheduler keeps its old address');
695
+ }
675
696
  if (pending) {
676
697
  context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
677
698
  await context.persist?.();
678
699
  }
700
+ context.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${JOBS_INTERNAL_NAME}:${JOBS_PORT}`);
679
701
  return { resourceId: appId };
680
702
  }
681
703
  }
@@ -3698,7 +3720,7 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
3698
3720
  const needs = service === 'jobs'
3699
3721
  ? {
3700
3722
  stepKey: null,
3701
- scopeKeys: [],
3723
+ scopeKeys: [JOBS_INTERNAL_URL_SCOPE_KEY],
3702
3724
  refs: [JOBS_ADMIN_TOKEN_REF],
3703
3725
  }
3704
3726
  : ARCHON_SERVICE_INPUTS[service];
@@ -3784,7 +3806,11 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
3784
3806
  }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
3785
3807
  }
3786
3808
  else {
3787
- envs.push({ key: 'JOBS_URL', value: `https://${platform.jobs.hostname}` }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
3809
+ envs.push(
3810
+ // Known gap: an Archon that is already applied keeps its old `JOBS_URL`
3811
+ // until its step runs again, because the planner does not compare it.
3812
+ // Tracked in a separate issue.
3813
+ { key: 'JOBS_URL', value: recorded[JOBS_INTERNAL_URL_SCOPE_KEY] }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
3788
3814
  }
3789
3815
  }
3790
3816
  return { envs };
@@ -7326,10 +7352,15 @@ export const PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY = 'platform_files_share_doma
7326
7352
  function normalHosts(hosts) {
7327
7353
  return [...new Set((hosts ?? []).map((h) => h.trim().toLowerCase()).filter(Boolean))].sort();
7328
7354
  }
7329
- /** The runner's Coolify `domains`, written only when the share set changed (a runner nobody gave a share host keeps none). */
7355
+ /**
7356
+ * The runner's Coolify `domains`, written only when the share set changed (a
7357
+ * runner nobody gave a share host keeps none). The share host is in the
7358
+ * company tunnel, which dials the proxy over plain http, so it is written
7359
+ * `http://`.
7360
+ */
7330
7361
  function filesShareDomainsPatch(recorded, shareDomains) {
7331
7362
  return shareDomainsChanged(recorded, shareDomains)
7332
- ? { domains: coolifyDomains(normalHosts(shareDomains)) }
7363
+ ? { domains: coolifyTunnelDomains(normalHosts(shareDomains)) }
7333
7364
  : {};
7334
7365
  }
7335
7366
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
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",