@wtfalch/foundry 0.20.0 → 0.21.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/bootstrap/declare.d.ts +13 -0
- package/dist/bootstrap/declare.js +23 -1
- package/dist/bootstrap/versions.d.ts +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/migrations/0046_platform_hc.sql +19 -0
- package/dist/plan/deploy-health.js +5 -1
- package/dist/plan/diff.js +23 -0
- package/dist/plan/executor.d.ts +34 -0
- package/dist/plan/executor.js +181 -28
- package/dist/plan/health-check-settings.d.ts +47 -0
- package/dist/plan/health-check-settings.js +47 -0
- package/dist/plan/health-targets.d.ts +86 -0
- package/dist/plan/health-targets.js +344 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/live.d.ts +12 -1
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/platform-diff.d.ts +124 -0
- package/dist/plan/platform-diff.js +287 -3
- package/dist/plan/platform-providers.d.ts +150 -2
- package/dist/plan/platform-providers.js +498 -42
- package/dist/plan/providers-readers.js +32 -1
- package/dist/plan/rollout.d.ts +9 -8
- package/dist/platform.d.ts +14 -1
- package/dist/platform.js +1 -0
- package/dist/store/postgres.js +8 -0
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +4 -0
- package/package.json +1 -1
- package/versions.json +2 -1
|
@@ -21,6 +21,19 @@ export declare const DEFAULT_GIT_REF = "main";
|
|
|
21
21
|
* file's loader follows.
|
|
22
22
|
*/
|
|
23
23
|
export declare const DEFAULT_MAIL_IMAGE_TAG: string;
|
|
24
|
+
/**
|
|
25
|
+
* #540: the Healthchecks image tag the platform's `hc` step runs.
|
|
26
|
+
* `platform.hc.gitRef` is an image tag, the same overload `mail.gitRef`
|
|
27
|
+
* carries. #540 shipped this floating at `'latest'`, with no
|
|
28
|
+
* `versions.json` pin and no `floatingTagBlocked` guard at all -- a real
|
|
29
|
+
* reviewed gap, closed round 2 the same way Stalwart's own tag is pinned:
|
|
30
|
+
* `versions.json`'s `hc` entry, checked against `healthchecks/healthchecks`'s
|
|
31
|
+
* real GitHub releases and Docker Hub tags (2026-10, latest stable `v4.4`)
|
|
32
|
+
* rather than guessed, and `hcImageTagBlocked` (`platform-providers.ts`)
|
|
33
|
+
* refuses a floating one the way `mailImageTagBlocked` already does for
|
|
34
|
+
* Stalwart.
|
|
35
|
+
*/
|
|
36
|
+
export declare const DEFAULT_HC_IMAGE_TAG: string;
|
|
24
37
|
export interface BootstrapCompany {
|
|
25
38
|
name: string;
|
|
26
39
|
slug: string;
|
|
@@ -22,6 +22,19 @@ export const DEFAULT_GIT_REF = 'main';
|
|
|
22
22
|
* file's loader follows.
|
|
23
23
|
*/
|
|
24
24
|
export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
|
|
25
|
+
/**
|
|
26
|
+
* #540: the Healthchecks image tag the platform's `hc` step runs.
|
|
27
|
+
* `platform.hc.gitRef` is an image tag, the same overload `mail.gitRef`
|
|
28
|
+
* carries. #540 shipped this floating at `'latest'`, with no
|
|
29
|
+
* `versions.json` pin and no `floatingTagBlocked` guard at all -- a real
|
|
30
|
+
* reviewed gap, closed round 2 the same way Stalwart's own tag is pinned:
|
|
31
|
+
* `versions.json`'s `hc` entry, checked against `healthchecks/healthchecks`'s
|
|
32
|
+
* real GitHub releases and Docker Hub tags (2026-10, latest stable `v4.4`)
|
|
33
|
+
* rather than guessed, and `hcImageTagBlocked` (`platform-providers.ts`)
|
|
34
|
+
* refuses a floating one the way `mailImageTagBlocked` already does for
|
|
35
|
+
* Stalwart.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_HC_IMAGE_TAG = VERSIONS.hc;
|
|
25
38
|
/**
|
|
26
39
|
* Every platform service except `sfu` is enabled, like the other steps: a
|
|
27
40
|
* service whose dependencies the host did not supply is reported blocked,
|
|
@@ -30,6 +43,8 @@ export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
|
|
|
30
43
|
* platform DNS record is written through it (F4.dns); its own Cloudflare API
|
|
31
44
|
* token is a held key (`cloudflare:edge:apiToken`), and until a human holds
|
|
32
45
|
* it the planner reports edge blocked, and auth, mail and archon with it.
|
|
46
|
+
* `hc` (#540) is on too: it needs no held key of its own, only edge's DNS,
|
|
47
|
+
* the same as mail and archon.
|
|
33
48
|
*/
|
|
34
49
|
const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
35
50
|
'auth',
|
|
@@ -40,6 +55,7 @@ const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
|
40
55
|
'integrations',
|
|
41
56
|
'archon',
|
|
42
57
|
'edge',
|
|
58
|
+
'hc',
|
|
43
59
|
]);
|
|
44
60
|
/**
|
|
45
61
|
* The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
|
|
@@ -115,7 +131,13 @@ export function declaredPlatformFor(apex, serverIpv4) {
|
|
|
115
131
|
services[key] = {
|
|
116
132
|
enabled,
|
|
117
133
|
hostname: defaultPlatformHostname(key, apex),
|
|
118
|
-
gitRef: !enabled
|
|
134
|
+
gitRef: !enabled
|
|
135
|
+
? ''
|
|
136
|
+
: key === 'mail'
|
|
137
|
+
? DEFAULT_MAIL_IMAGE_TAG
|
|
138
|
+
: key === 'hc'
|
|
139
|
+
? DEFAULT_HC_IMAGE_TAG
|
|
140
|
+
: DEFAULT_GIT_REF,
|
|
119
141
|
};
|
|
120
142
|
}
|
|
121
143
|
return {
|
package/dist/index.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
|
30
30
|
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
|
|
31
31
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
32
32
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
33
|
+
export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './plan/index.js';
|
|
34
|
+
export { healthTargets, platformHealthTargets } from './plan/index.js';
|
|
33
35
|
export type { ScopeResolver, RecordedIds } from './plan/index.js';
|
|
34
36
|
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
35
37
|
export type { ZitadelListAppsRawApp } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesF
|
|
|
18
18
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
19
19
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
20
20
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
21
|
+
export { healthTargets, platformHealthTargets } from './plan/index.js';
|
|
21
22
|
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
22
23
|
export { adaptZitadelListApps } from './plan/index.js';
|
|
23
24
|
export { checkMailIdentityAudience } from './plan/index.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- @wtfalch/foundry: #540 -- the platform's own heartbeat server (self-hosted
|
|
2
|
+
-- Healthchecks), added to the declared platform the same way every other
|
|
3
|
+
-- shared service here was (#165, #317). Mirrors `src/store/schema.ts`'s
|
|
4
|
+
-- `foundryDeclaredPlatform` table additions. Backfills `hc_hostname` the
|
|
5
|
+
-- same way `0043_platform_names.sql` backfilled `names_hostname`: the
|
|
6
|
+
-- column must not be empty (`src/platform.ts`'s `validatePlatform`), and
|
|
7
|
+
-- the table already carries its one live row by the time this runs.
|
|
8
|
+
|
|
9
|
+
alter table foundry_declared_platform
|
|
10
|
+
add column if not exists hc_enabled boolean not null default false,
|
|
11
|
+
add column if not exists hc_hostname text,
|
|
12
|
+
add column if not exists hc_git_ref text not null default '';
|
|
13
|
+
|
|
14
|
+
update foundry_declared_platform
|
|
15
|
+
set hc_hostname = 'hc.' || apex
|
|
16
|
+
where hc_hostname is null;
|
|
17
|
+
|
|
18
|
+
alter table foundry_declared_platform
|
|
19
|
+
alter column hc_hostname set not null;
|
|
@@ -62,6 +62,8 @@
|
|
|
62
62
|
*/
|
|
63
63
|
const DEFAULT_READY_TIMEOUT_MS = 300_000;
|
|
64
64
|
const DEFAULT_READY_INTERVAL_MS = 5_000;
|
|
65
|
+
/** One health request's own limit: a connection that accepts and never answers counts as not healthy yet, instead of blocking the whole wait. */
|
|
66
|
+
const HEALTH_REQUEST_TIMEOUT_MS = 5_000;
|
|
65
67
|
/** The one terminal `CoolifyDeploymentStatus` that means the new version landed. */
|
|
66
68
|
const DEPLOYMENT_SUCCEEDED = 'finished';
|
|
67
69
|
/**
|
|
@@ -135,7 +137,9 @@ export async function waitForNewDeployment(coolify, appId, health, runtime, slee
|
|
|
135
137
|
}
|
|
136
138
|
async function matchesHealth(fetchImpl, health) {
|
|
137
139
|
try {
|
|
138
|
-
const response = await fetchImpl(health.url
|
|
140
|
+
const response = await fetchImpl(health.url, {
|
|
141
|
+
signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS),
|
|
142
|
+
});
|
|
139
143
|
if (!response.ok)
|
|
140
144
|
return false;
|
|
141
145
|
if (health.kind === 'ready')
|
package/dist/plan/diff.js
CHANGED
|
@@ -1955,6 +1955,29 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1955
1955
|
blocked,
|
|
1956
1956
|
};
|
|
1957
1957
|
}
|
|
1958
|
+
// #543: a human turning the health check off in the Coolify UI (or an app
|
|
1959
|
+
// applied before this patch existed) is noticed the same way `hasLogVolume`
|
|
1960
|
+
// just above is -- `coolifyApp` already sends the quartet on every run, so
|
|
1961
|
+
// replanning this one step (the same action, nothing extra) is the whole
|
|
1962
|
+
// repair. A reader that did not check (`healthCheckEnabled` absent) reads
|
|
1963
|
+
// as compliant, like `hasLogVolume`.
|
|
1964
|
+
// Only for an entry `coolifyApp` would set the check on (a static entry,
|
|
1965
|
+
// or one with a declared port): otherwise the repair could never land and
|
|
1966
|
+
// every apply would replan it.
|
|
1967
|
+
if (live.config.healthCheckEnabled === false &&
|
|
1968
|
+
(entry.buildPack === 'static' || entry.port !== null)) {
|
|
1969
|
+
return {
|
|
1970
|
+
step: {
|
|
1971
|
+
action,
|
|
1972
|
+
step: {
|
|
1973
|
+
description: `turn Coolify's health check back on for ${org.slug}'s ${label} (#543)`,
|
|
1974
|
+
destroys: false,
|
|
1975
|
+
migrates: false,
|
|
1976
|
+
},
|
|
1977
|
+
},
|
|
1978
|
+
blocked,
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1958
1981
|
// #239 (rule 4), from live state: an app with a database but no
|
|
1959
1982
|
// `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
|
|
1960
1983
|
// got the push. Names only, never values. A reader that did not list env
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
|
|
|
2
2
|
import type { DeclaredOrg, HostingBuildPack } from '../org.js';
|
|
3
3
|
import type { DeclaredOrgStore } from '../store.js';
|
|
4
4
|
import { type BrokerPort } from './broker-entries.js';
|
|
5
|
+
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
5
6
|
import { type StepAction } from './diff.js';
|
|
6
7
|
import { type AccessPort, type EdgePlatformAccess } from './edge-access.js';
|
|
7
8
|
import { type DnsPort, type EdgePlatformDns } from './edge-dns.js';
|
|
@@ -384,6 +385,8 @@ export interface CoolifyPort {
|
|
|
384
385
|
enabled: boolean;
|
|
385
386
|
save_s3: boolean;
|
|
386
387
|
s3_storage_uuid?: string;
|
|
388
|
+
/** The databases this schedule dumps, comma separated. Empty means the resource's first database. */
|
|
389
|
+
databases_to_backup?: string | null;
|
|
387
390
|
}>>;
|
|
388
391
|
/**
|
|
389
392
|
* #257: schedules dumps of a database to the registered S3 storage --
|
|
@@ -1451,6 +1454,17 @@ export interface FoundryStepExecutorPorts {
|
|
|
1451
1454
|
* passes the same store instance it constructs `FoundryPlanApply` with.
|
|
1452
1455
|
*/
|
|
1453
1456
|
declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
|
|
1457
|
+
/**
|
|
1458
|
+
* #543: clock, sleep and `fetch` for the health-check wait every company
|
|
1459
|
+
* app's own redeploy now goes through (`deploy-health.ts`'s
|
|
1460
|
+
* `waitForNewDeployment`) -- the same override shape `platform-providers.ts`'s
|
|
1461
|
+
* own `PlatformMailDeps.runtime`/`PlatformArchonDeps.runtime` already take,
|
|
1462
|
+
* here on one shared port instead of one per platform step since this
|
|
1463
|
+
* executor is the one class every company-app step runs through. Optional:
|
|
1464
|
+
* without it, {@link defaultSleep} (plain `setTimeout`) and the real
|
|
1465
|
+
* `fetch`/`Date.now` are used.
|
|
1466
|
+
*/
|
|
1467
|
+
runtime?: DeployHealthRuntime;
|
|
1454
1468
|
}
|
|
1455
1469
|
/**
|
|
1456
1470
|
* Thrown by name for a step this executor deliberately does not implement:
|
|
@@ -1462,6 +1476,26 @@ export interface FoundryStepExecutorPorts {
|
|
|
1462
1476
|
export declare class NotBuiltError extends Error {
|
|
1463
1477
|
constructor(step: string, detail: string);
|
|
1464
1478
|
}
|
|
1479
|
+
/**
|
|
1480
|
+
* `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
|
|
1481
|
+
* from the public site itself at `org.domain` (used directly there). Called
|
|
1482
|
+
* from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
|
|
1483
|
+
* both of which only run once `org.site.enabled` is true, so `org.domain`
|
|
1484
|
+
* is non-null by the same `validateSite` guarantee
|
|
1485
|
+
* {@link tunnelIngressHostnames}'s own doc comment cites.
|
|
1486
|
+
*/
|
|
1487
|
+
export declare function cmsHostnameFor(org: DeclaredOrg): string;
|
|
1488
|
+
/**
|
|
1489
|
+
* #145's "Decided" (2026-09-27): the calls service's own public hostname.
|
|
1490
|
+
* `calls.usesDomain: false` (or no `domain`) is served flat, like
|
|
1491
|
+
* `<slug>-stage.oikos.host` and `<slug>-pr-<n>.oikos.host` (`../slug.ts`) --
|
|
1492
|
+
* the shared tunnel's wildcard `*.oikos.host` DNS record already reaches it,
|
|
1493
|
+
* so nothing changes in `scripts/shared-ingress.mjs`. `calls.usesDomain: true`
|
|
1494
|
+
* gets `calls.<domain>` instead, on its own per-org tunnel
|
|
1495
|
+
* ({@link tunnelIngressHostnames}), the same subdomain-of-the-org's-own-domain
|
|
1496
|
+
* shape {@link cmsHostnameFor} uses for `cms.<domain>`.
|
|
1497
|
+
*/
|
|
1498
|
+
export declare function callsHostnameFor(org: DeclaredOrg): string;
|
|
1465
1499
|
/**
|
|
1466
1500
|
* The health URLs an outside prober should call for this org (#43): one per
|
|
1467
1501
|
* app foundry runs for it. A hosting org's app answers at its own hostname,
|
package/dist/plan/executor.js
CHANGED
|
@@ -9,6 +9,7 @@ import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, cal
|
|
|
9
9
|
import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
10
10
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
11
11
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
12
|
+
import { waitForNewDeployment, } from './deploy-health.js';
|
|
12
13
|
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
13
14
|
import { createEdgeAccess, } from './edge-access.js';
|
|
14
15
|
import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
|
|
@@ -18,6 +19,7 @@ import { createEdgeZones } from './edge-zones.js';
|
|
|
18
19
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
19
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';
|
|
20
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
|
+
import { healthCheckSettings } from './health-check-settings.js';
|
|
21
23
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
22
24
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
23
25
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
@@ -84,7 +86,9 @@ function normalizedLoginBaseUri(uri) {
|
|
|
84
86
|
* is non-null by the same `validateSite` guarantee
|
|
85
87
|
* {@link tunnelIngressHostnames}'s own doc comment cites.
|
|
86
88
|
*/
|
|
87
|
-
|
|
89
|
+
// #541: exported so `health-targets.ts` reuses this hostname rule rather
|
|
90
|
+
// than restating it; nothing else about this function changes.
|
|
91
|
+
export function cmsHostnameFor(org) {
|
|
88
92
|
return `cms.${org.domain}`;
|
|
89
93
|
}
|
|
90
94
|
/**
|
|
@@ -97,7 +101,8 @@ function cmsHostnameFor(org) {
|
|
|
97
101
|
* ({@link tunnelIngressHostnames}), the same subdomain-of-the-org's-own-domain
|
|
98
102
|
* shape {@link cmsHostnameFor} uses for `cms.<domain>`.
|
|
99
103
|
*/
|
|
100
|
-
|
|
104
|
+
// #541: exported for the same reason `cmsHostnameFor` just above now is.
|
|
105
|
+
export function callsHostnameFor(org) {
|
|
101
106
|
return org.calls.usesDomain && org.domain
|
|
102
107
|
? `calls.${org.domain}`
|
|
103
108
|
: `${org.slug}-calls.oikos.host`;
|
|
@@ -233,6 +238,14 @@ const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
|
|
|
233
238
|
const CMS_REPO = 'wtfalch/cms';
|
|
234
239
|
const CMS_APP_DOCKERFILE = '/Dockerfile';
|
|
235
240
|
const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
|
|
241
|
+
/**
|
|
242
|
+
* #543: both of `wtfalch/cms`'s apps are Next.js hosts on the same
|
|
243
|
+
* app-template convention as Archon/edge/ai's runner/Boule -- port 3000,
|
|
244
|
+
* `/api/health` (`healthUrls`'s own default for a non-static entry). Neither
|
|
245
|
+
* create call below passes `ports_exposes` (unlike Agora's #506 fix), so
|
|
246
|
+
* this is the port Coolify's own default build already listens its proxy on.
|
|
247
|
+
*/
|
|
248
|
+
const CMS_APP_PORT = 3000;
|
|
236
249
|
/**
|
|
237
250
|
* #164: the repo `coolifyAgora` builds the org's Agora from
|
|
238
251
|
* (https://github.com/wtfalch/agora) -- the issue's own "How" names this
|
|
@@ -266,6 +279,8 @@ const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
|
|
|
266
279
|
*/
|
|
267
280
|
const BOULE_REPO = 'wtfalch/boule';
|
|
268
281
|
const BOULE_APP_DOCKERFILE = '/Dockerfile';
|
|
282
|
+
/** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
|
|
283
|
+
const BOULE_PORT = 3000;
|
|
269
284
|
/**
|
|
270
285
|
* Every app foundry deploys keeps its own logs across a redeploy, so a
|
|
271
286
|
* database outage does not also erase the only other copy of the evidence
|
|
@@ -597,6 +612,21 @@ const CALLS_SERVICE_DB_SCOPE_KEY = 'coolify_calls_service_database';
|
|
|
597
612
|
const CALLS_SERVICE_REPO = 'wtfalch/calls';
|
|
598
613
|
/** `packages/service/Dockerfile`, built from the repo root (that Dockerfile's own top comment). */
|
|
599
614
|
const CALLS_SERVICE_DOCKERFILE = '/packages/service/Dockerfile';
|
|
615
|
+
/**
|
|
616
|
+
* #543: the calls service's own health route. Restated, not imported, from
|
|
617
|
+
* `health-targets.ts`'s private `CALLS_HEALTH_PATH` of the same value --
|
|
618
|
+
* issue #543's own text keeps that issue's (#541) diff and this one
|
|
619
|
+
* independent of each other.
|
|
620
|
+
*/
|
|
621
|
+
const CALLS_HEALTH_CHECK_PATH = '/healthz';
|
|
622
|
+
/**
|
|
623
|
+
* #543: the chat service's own health route, reached through Agora's proxy
|
|
624
|
+
* (`chat-matrix.ts`'s "NO PUBLIC HOSTNAME OF ITS OWN": Synapse and MAS have
|
|
625
|
+
* none, only this). Restated from `health-targets.ts`'s private
|
|
626
|
+
* `CHAT_SERVICE_HEALTH_PATH` of the same value, for the same reason as
|
|
627
|
+
* {@link CALLS_HEALTH_CHECK_PATH} above.
|
|
628
|
+
*/
|
|
629
|
+
const CHAT_SERVICE_HEALTH_CHECK_PATH = '/_chat/health';
|
|
600
630
|
/**
|
|
601
631
|
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
602
632
|
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
@@ -642,6 +672,43 @@ export async function getDatabaseStarted(coolify, dbId) {
|
|
|
642
672
|
await sleep(graceMs);
|
|
643
673
|
return db;
|
|
644
674
|
}
|
|
675
|
+
/**
|
|
676
|
+
* #543: the plain `setTimeout`-based sleep every company-app deploy wait
|
|
677
|
+
* falls back to when `FoundryStepExecutorPorts.runtime.sleep` is not given --
|
|
678
|
+
* the same default `platform-providers.ts`'s own per-step `defaultSleep`
|
|
679
|
+
* constants use.
|
|
680
|
+
*/
|
|
681
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
682
|
+
/**
|
|
683
|
+
* #543 review: a deploy waits with `'ready'` on a public url only when
|
|
684
|
+
* nothing still has to create or change the route to it. Otherwise it is
|
|
685
|
+
* `'deployment-only'`, so a fresh company is not failed for a tunnel, DNS
|
|
686
|
+
* record or Agora that a later step in the same plan creates. From
|
|
687
|
+
* `recorded`: a hostname under `org.domain` has a route once the tunnel step
|
|
688
|
+
* published its DNS record ({@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY});
|
|
689
|
+
* a hostname elsewhere is on the shared wildcard tunnel and already routed.
|
|
690
|
+
* `viaAgora` (the chat service, reached through Agora's proxy) also needs
|
|
691
|
+
* Agora deployed ({@link AGORA_DEPLOYED_SCOPE_KEY}). A hostname behind
|
|
692
|
+
* Cloudflare Access is never probed: with no credentials every answer is a
|
|
693
|
+
* redirect or 403.
|
|
694
|
+
*/
|
|
695
|
+
function publicHealthCheck(org, recorded, hostname, path, options = {}) {
|
|
696
|
+
const app = appEntry(org.hosting);
|
|
697
|
+
const behindAccess = (org.hosting.access ?? []).some((rule) => rule.hostname === hostname) ||
|
|
698
|
+
(app !== undefined &&
|
|
699
|
+
app.accessEmails.length > 0 &&
|
|
700
|
+
entryHostnames(org, app).includes(hostname));
|
|
701
|
+
if (behindAccess)
|
|
702
|
+
return { kind: 'deployment-only' };
|
|
703
|
+
const routed = (host) => !org.domain ||
|
|
704
|
+
!(host === org.domain || host.endsWith(`.${org.domain}`)) ||
|
|
705
|
+
(recorded[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY] ?? '').split(',').includes(host);
|
|
706
|
+
const agoraReady = !options.viaAgora ||
|
|
707
|
+
(Boolean(recorded[AGORA_DEPLOYED_SCOPE_KEY]) && routed(agoraHostnameFor(org)));
|
|
708
|
+
if (!agoraReady || !routed(hostname))
|
|
709
|
+
return { kind: 'deployment-only' };
|
|
710
|
+
return { kind: 'ready', url: `https://${hostname}${path}` };
|
|
711
|
+
}
|
|
645
712
|
/** Timing for the wait in {@link getDatabaseStarted}; a test swaps `sleep` and `now` for a fake clock. */
|
|
646
713
|
export const databaseStartWait = {
|
|
647
714
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -1682,6 +1749,20 @@ export class FoundryStepExecutor {
|
|
|
1682
1749
|
}
|
|
1683
1750
|
if (want.autoDeploy !== have.autoDeploy)
|
|
1684
1751
|
patch.is_auto_deploy_enabled = want.autoDeploy;
|
|
1752
|
+
// #543: the health-check quartet too, every run -- the same reason
|
|
1753
|
+
// Archon's own doc comment gives (without one, Coolify's rolling update
|
|
1754
|
+
// has nothing to wait on and stops the old container immediately). The
|
|
1755
|
+
// port Coolify's own health probe reaches is whatever `ports_exposes`
|
|
1756
|
+
// names: 80 for a static entry (Coolify's own web server), or the
|
|
1757
|
+
// entry's declared `port`. An entry with neither (an arbitrary buildpack,
|
|
1758
|
+
// no declared port) gets none here -- there is no port this executor
|
|
1759
|
+
// could honestly name for Coolify to probe, the same "nothing to check
|
|
1760
|
+
// instead" reasoning `platform:ai`'s worker and `platform:files`'
|
|
1761
|
+
// migrate job are left out of #543's own quartet for.
|
|
1762
|
+
const healthCheckPort = isStatic ? 80 : want.port;
|
|
1763
|
+
if (healthCheckPort !== null) {
|
|
1764
|
+
Object.assign(patch, healthCheckSettings(entry.healthPath ?? (isStatic ? '/' : '/api/health'), healthCheckPort));
|
|
1765
|
+
}
|
|
1685
1766
|
// Create names the app after `domains`; a changed name reaches a live app
|
|
1686
1767
|
// here (rule 3). The internal name is set after create, as the calls
|
|
1687
1768
|
// stack's apps do.
|
|
@@ -1759,10 +1840,23 @@ export class FoundryStepExecutor {
|
|
|
1759
1840
|
// declared appEnv changed.
|
|
1760
1841
|
// A new folder needs a build, which a restart does not do, so a settings
|
|
1761
1842
|
// change deploys instead (rule 3), and that carries the env too.
|
|
1762
|
-
if (settingsNeedDeploy)
|
|
1763
|
-
|
|
1764
|
-
|
|
1843
|
+
if (settingsNeedDeploy) {
|
|
1844
|
+
// #543: every deploy waits for health through the shared helper, never
|
|
1845
|
+
// a fire-and-forget `deploy` call -- `names[0]` is the same hostname
|
|
1846
|
+
// `healthUrls` probes for this entry; an internal entry (no hostname)
|
|
1847
|
+
// has nothing this executor could reach from outside the app's own
|
|
1848
|
+
// Docker network, so `'deployment-only'` is the honest fallback there
|
|
1849
|
+
// (Coolify's own deployment record is still the whole check).
|
|
1850
|
+
const [hostname] = names;
|
|
1851
|
+
const healthPath = entry.healthPath ?? (isStatic ? '/' : '/api/health');
|
|
1852
|
+
const runtime = this.ports.runtime ?? {};
|
|
1853
|
+
await waitForNewDeployment(this.ports.coolify, appId, hostname
|
|
1854
|
+
? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
|
|
1855
|
+
: { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
|
|
1856
|
+
}
|
|
1857
|
+
else if ((appEnv.changed || dbNeedsRestart) && existing) {
|
|
1765
1858
|
await this.ports.coolify.restart(appId);
|
|
1859
|
+
}
|
|
1766
1860
|
// Recorded only after the redeploy succeeded: a failed one leaves the old
|
|
1767
1861
|
// value, so the next plan replans (rule 3). The db marker is not a URL.
|
|
1768
1862
|
// A port taken back to null was not patched, so the marker keeps the old
|
|
@@ -2602,6 +2696,21 @@ export class FoundryStepExecutor {
|
|
|
2602
2696
|
dockerfileLocation: CMS_APP_DOCKERFILE,
|
|
2603
2697
|
domain: cmsHostnameFor(org),
|
|
2604
2698
|
});
|
|
2699
|
+
// #543: the health-check quartet, every run, on both apps -- their own
|
|
2700
|
+
// `/api/health` (`healthUrls`'s own default for site/cms), on the port
|
|
2701
|
+
// {@link CMS_APP_PORT}'s own doc comment explains. Unlike every other
|
|
2702
|
+
// company app in this file, neither app here is ever deployed through an
|
|
2703
|
+
// explicit `coolify.deploy`/`waitForNewDeployment` call: `findOrCreateCmsApp`
|
|
2704
|
+
// (below) never sets `instant_deploy` either, the same gap #504 found and
|
|
2705
|
+
// fixed for Agora and Boule -- fixing that for cms/site is its own,
|
|
2706
|
+
// separate issue (out of #543's scope: this issue adds waits to existing
|
|
2707
|
+
// deploys, it does not add a deploy where none exists).
|
|
2708
|
+
await this.ports.coolify.updateApplication(site.appId, {
|
|
2709
|
+
...healthCheckSettings('/api/health', CMS_APP_PORT),
|
|
2710
|
+
});
|
|
2711
|
+
await this.ports.coolify.updateApplication(cms.appId, {
|
|
2712
|
+
...healthCheckSettings('/api/health', CMS_APP_PORT),
|
|
2713
|
+
});
|
|
2605
2714
|
// Every app foundry deploys keeps its own logs (#75) -- reused as-is for
|
|
2606
2715
|
// both of these two, the same unconditional call `coolifyApp` makes for
|
|
2607
2716
|
// its own one app.
|
|
@@ -2979,10 +3088,21 @@ export class FoundryStepExecutor {
|
|
|
2979
3088
|
// converges. A brand new app or a freshly minted secret set are the only
|
|
2980
3089
|
// two reasons the file just written could differ from what the container
|
|
2981
3090
|
// already has -- reusing existing secrets means byte-identical content.
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
3091
|
+
//
|
|
3092
|
+
// #543: both waits are `'deployment-only'` -- Synapse and MAS have their
|
|
3093
|
+
// own health check off (just above: "Neither Synapse's nor MAS's health
|
|
3094
|
+
// check can pass before the other is up"), so there is no url either
|
|
3095
|
+
// could honestly be probed at; `coolify.deployAndWait` reaching Coolify's
|
|
3096
|
+
// own deployment record is still the whole check, same as `platform:ai`'s
|
|
3097
|
+
// worker.
|
|
3098
|
+
const runtime = this.ports.runtime ?? {};
|
|
3099
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
3100
|
+
if (!synapse.existed || secrets.changed) {
|
|
3101
|
+
await waitForNewDeployment(this.ports.coolify, synapse.appId, { kind: 'deployment-only' }, runtime, sleep, 'coolify:calls', `${org.slug}'s calls Synapse stays at its previous deploy`);
|
|
3102
|
+
}
|
|
3103
|
+
if (!mas.existed || secrets.changed) {
|
|
3104
|
+
await waitForNewDeployment(this.ports.coolify, mas.appId, { kind: 'deployment-only' }, runtime, sleep, 'coolify:calls', `${org.slug}'s calls MAS stays at its previous deploy`);
|
|
3105
|
+
}
|
|
2986
3106
|
return { resourceId: mas.appId };
|
|
2987
3107
|
}
|
|
2988
3108
|
/**
|
|
@@ -3198,6 +3318,15 @@ export class FoundryStepExecutor {
|
|
|
3198
3318
|
*/
|
|
3199
3319
|
async coolifyChat(action, org, context) {
|
|
3200
3320
|
const recorded = context.recordedIds();
|
|
3321
|
+
// #543: every deploy below waits for health. The chat service answers
|
|
3322
|
+
// `CHAT_SERVICE_HEALTH_CHECK_PATH` only through Agora's own proxy
|
|
3323
|
+
// (`chat-matrix.ts`'s "NO PUBLIC HOSTNAME OF ITS OWN" -- the service has
|
|
3324
|
+
// no domain of its own), so that is the url every `'ready'` check below
|
|
3325
|
+
// probes; Synapse and MAS stay `'deployment-only'`, the same reason
|
|
3326
|
+
// `coolifyCalls`'s own comment gives for its Synapse/MAS.
|
|
3327
|
+
const runtime = this.ports.runtime ?? {};
|
|
3328
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
3329
|
+
const chatServiceHealth = () => publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), CHAT_SERVICE_HEALTH_CHECK_PATH, { viaAgora: true });
|
|
3201
3330
|
if (recorded[`coolify:chat:${action.key}`]) {
|
|
3202
3331
|
// #456: `planChat` plans this distinct, non-re-key action for a live
|
|
3203
3332
|
// chat app that predates the owner/runtime database split (#239) --
|
|
@@ -3222,7 +3351,7 @@ export class FoundryStepExecutor {
|
|
|
3222
3351
|
throw new Error(`coolify:chat database split for ${org.slug}: the chat service's database (${serviceDbId}) has no internal_db_url -- it may have been deleted or is unreachable; this is not expected for an already-live chat`);
|
|
3223
3352
|
}
|
|
3224
3353
|
if (push.changed || !recorded[markerKey]) {
|
|
3225
|
-
await this.ports.coolify.deploy
|
|
3354
|
+
await waitForNewDeployment(this.ports.coolify, serviceAppId, chatServiceHealth(), runtime, sleep, 'coolify:chat', `${org.slug}'s chat service stays at its previous deploy`);
|
|
3226
3355
|
}
|
|
3227
3356
|
context.recordScopeId(markerKey, '1');
|
|
3228
3357
|
return { resourceId: serviceAppId };
|
|
@@ -3245,8 +3374,9 @@ export class FoundryStepExecutor {
|
|
|
3245
3374
|
]);
|
|
3246
3375
|
// Deploy first, marker last: a failed deploy leaves the marker unset,
|
|
3247
3376
|
// so the next plan still carries this step and the container gets the env.
|
|
3248
|
-
if (client.changed || !hadMarker)
|
|
3249
|
-
await this.ports.coolify.deploy
|
|
3377
|
+
if (client.changed || !hadMarker) {
|
|
3378
|
+
await waitForNewDeployment(this.ports.coolify, serviceAppId, chatServiceHealth(), runtime, sleep, 'coolify:chat', `${org.slug}'s chat service stays at its previous deploy`);
|
|
3379
|
+
}
|
|
3250
3380
|
context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, client.appId);
|
|
3251
3381
|
return { resourceId: serviceAppId };
|
|
3252
3382
|
}
|
|
@@ -3348,6 +3478,8 @@ export class FoundryStepExecutor {
|
|
|
3348
3478
|
});
|
|
3349
3479
|
await this.ports.coolify.updateApplication(service.appId, {
|
|
3350
3480
|
custom_internal_name: names.service,
|
|
3481
|
+
// No Coolify health check on the chat service (#543 review): its image
|
|
3482
|
+
// is node:22-alpine with no curl, which Coolify's container check runs.
|
|
3351
3483
|
});
|
|
3352
3484
|
context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
3353
3485
|
context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
|
|
@@ -3414,7 +3546,11 @@ export class FoundryStepExecutor {
|
|
|
3414
3546
|
secrets.changed ||
|
|
3415
3547
|
(app === service && (serviceDbPush.changed || introspection.changed)) ||
|
|
3416
3548
|
!isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
|
|
3417
|
-
|
|
3549
|
+
// #543: `'ready'` for the service (it has a real route, probed
|
|
3550
|
+
// through Agora); `'deployment-only'` for Synapse/MAS, same as this
|
|
3551
|
+
// function's two re-key paths above and `coolifyCalls`'s own
|
|
3552
|
+
// Synapse/MAS.
|
|
3553
|
+
await waitForNewDeployment(this.ports.coolify, app.appId, app === service ? chatServiceHealth() : { kind: 'deployment-only' }, runtime, sleep, 'coolify:chat', `${org.slug}'s chat ${app === service ? 'service' : app === synapse ? 'Synapse' : 'MAS'} stays at its previous deploy`);
|
|
3418
3554
|
}
|
|
3419
3555
|
}
|
|
3420
3556
|
// After the write and the deploys, so a failure before them leaves the
|
|
@@ -3502,6 +3638,8 @@ export class FoundryStepExecutor {
|
|
|
3502
3638
|
// step again for a live app whose marker is missing or stale, so an app
|
|
3503
3639
|
// made before this existed gets its name here. The marker, recorded last,
|
|
3504
3640
|
// tells `desiredAgoraEnv` that the name and the env are both in place.
|
|
3641
|
+
// No Coolify health check on the calls service (#543 review): its image
|
|
3642
|
+
// is node:24-alpine with no curl, which Coolify's container check runs.
|
|
3505
3643
|
await this.ports.coolify.updateApplication(app.appId, { custom_internal_name: name });
|
|
3506
3644
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3507
3645
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
@@ -3569,7 +3707,11 @@ export class FoundryStepExecutor {
|
|
|
3569
3707
|
// existed, still pointed at a role nothing has created.
|
|
3570
3708
|
const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
|
|
3571
3709
|
if (app.existed && dbSplitNeedsRefresh) {
|
|
3572
|
-
|
|
3710
|
+
// #543: every deploy waits for health -- the calls service's own
|
|
3711
|
+
// public hostname and `/healthz`, the same path the quartet above
|
|
3712
|
+
// just set on the app.
|
|
3713
|
+
const runtime = this.ports.runtime ?? {};
|
|
3714
|
+
await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), callsHostnameFor(org), CALLS_HEALTH_CHECK_PATH), runtime, runtime.sleep ?? defaultSleep, 'coolify:calls-service', `${org.slug}'s calls service stays at its previous deploy`);
|
|
3573
3715
|
}
|
|
3574
3716
|
else if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
|
|
3575
3717
|
await this.ports.coolify.restart(app.appId);
|
|
@@ -3783,11 +3925,14 @@ export class FoundryStepExecutor {
|
|
|
3783
3925
|
const portDiffers = app.existed &&
|
|
3784
3926
|
(await this.ports.coolify.getApplication(app.appId)).ports_exposes !==
|
|
3785
3927
|
AGORA_APP_PORTS_EXPOSES;
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3928
|
+
// #543: the health-check quartet, every run, like Archon's own patch --
|
|
3929
|
+
// Agora is static (Caddy on {@link AGORA_APP_PORTS_EXPOSES}), so the only
|
|
3930
|
+
// health route available is a plain page load at `/` (the same depth
|
|
3931
|
+
// `health-targets.ts` gives it: `'page'`, not an API).
|
|
3932
|
+
await this.ports.coolify.updateApplication(app.appId, {
|
|
3933
|
+
...(portDiffers ? { ports_exposes: AGORA_APP_PORTS_EXPOSES } : {}),
|
|
3934
|
+
...healthCheckSettings('/', Number(AGORA_APP_PORTS_EXPOSES)),
|
|
3935
|
+
});
|
|
3791
3936
|
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
3792
3937
|
// Coolify's own env store matching `env`, which is why that store alone
|
|
3793
3938
|
// can no longer tell a changed value from one already live.
|
|
@@ -3806,16 +3951,16 @@ export class FoundryStepExecutor {
|
|
|
3806
3951
|
// confirmed (the marker never changes value once set), never again -- a
|
|
3807
3952
|
// live app is only ever restarted from here on, unless #506's port fix
|
|
3808
3953
|
// below needs its own deploy.
|
|
3809
|
-
if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY]) {
|
|
3810
|
-
|
|
3954
|
+
if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY] || portDiffers) {
|
|
3955
|
+
// #543/#506: every deploy waits for health -- Agora's own page at `/`.
|
|
3956
|
+
// A first deploy and a port fix share this one wait (a port fix needs
|
|
3957
|
+
// its own full deploy; env drift alone only needs the cheaper restart
|
|
3958
|
+
// in the branch below, and this deploy already carries this run's env,
|
|
3959
|
+
// pushed above, so the two never both run).
|
|
3960
|
+
const runtime = this.ports.runtime ?? {};
|
|
3961
|
+
await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), '/'), runtime, runtime.sleep ?? defaultSleep, 'coolify:agora', `${org.slug}'s Agora stays at its previous deploy`);
|
|
3811
3962
|
context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
|
|
3812
3963
|
}
|
|
3813
|
-
else if (portDiffers) {
|
|
3814
|
-
// #506: a port fix needs its own full deploy -- env drift alone only
|
|
3815
|
-
// needs the cheaper restart, and this deploy already carries this
|
|
3816
|
-
// run's env (pushed above), so the two never both run.
|
|
3817
|
-
await this.ports.coolify.deploy(app.appId);
|
|
3818
|
-
}
|
|
3819
3964
|
else if (app.existed && (envChanged || !alreadyActivated)) {
|
|
3820
3965
|
await this.ports.coolify.restart(app.appId);
|
|
3821
3966
|
}
|
|
@@ -3882,6 +4027,12 @@ export class FoundryStepExecutor {
|
|
|
3882
4027
|
}));
|
|
3883
4028
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3884
4029
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
4030
|
+
// #543: the health-check quartet, every run, like Archon's own patch --
|
|
4031
|
+
// Boule's own `/api/health` (`healthUrls`'s default) on its Dockerfile's
|
|
4032
|
+
// `EXPOSE 3000` ({@link BOULE_PORT}'s own doc comment).
|
|
4033
|
+
await this.ports.coolify.updateApplication(app.appId, {
|
|
4034
|
+
...healthCheckSettings('/api/health', BOULE_PORT),
|
|
4035
|
+
});
|
|
3885
4036
|
const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
|
|
3886
4037
|
.filter((e) => !e.is_preview)
|
|
3887
4038
|
.map((e) => [e.key, e.value]));
|
|
@@ -3906,7 +4057,9 @@ export class FoundryStepExecutor {
|
|
|
3906
4057
|
// but never started. Deployed once this executor has confirmed, never
|
|
3907
4058
|
// again -- a live app is only ever restarted from here on.
|
|
3908
4059
|
if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
|
|
3909
|
-
|
|
4060
|
+
// #543: every deploy waits for health -- Boule's own public hostname.
|
|
4061
|
+
const runtime = this.ports.runtime ?? {};
|
|
4062
|
+
await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), bouleHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:boule', `${org.slug}'s Boule stays at its previous deploy`);
|
|
3910
4063
|
context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
|
|
3911
4064
|
}
|
|
3912
4065
|
else if (app.existed &&
|