@wtfalch/foundry 0.16.8 → 0.16.10
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/plan/diff.js +16 -2
- package/dist/plan/executor.d.ts +20 -0
- package/dist/plan/executor.js +9 -0
- package/dist/plan/platform-providers.d.ts +15 -4
- package/dist/plan/platform-providers.js +130 -15
- package/package.json +1 -1
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
-
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
3
|
+
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
4
|
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
6
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
@@ -1003,6 +1003,15 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
|
|
|
1003
1003
|
* #177: blocked, not planned, while `filesReady` is false -- the platform's
|
|
1004
1004
|
* own files host (#171) is off or not yet applied, so there is nothing here
|
|
1005
1005
|
* for `FilesOrganisationPort.provisionOrganisation` to reach.
|
|
1006
|
+
*
|
|
1007
|
+
* #273: `desired.agoraOrigin` is every org's own Agora origin (#164's
|
|
1008
|
+
* `agoraHostnameFor`, always on, never blocked on anything this step
|
|
1009
|
+
* doesn't already wait for) -- `executor.ts`'s `filesOrganisation` adds it
|
|
1010
|
+
* to the files host's CORS allowlist alongside provisioning. Known gap: a
|
|
1011
|
+
* later domain change that moves Agora's hostname is not replanned here,
|
|
1012
|
+
* since this step (like `planIssuer`) has nothing to diff once the org's
|
|
1013
|
+
* Files organisation already exists -- the old origin is left allowed
|
|
1014
|
+
* rather than silently dropped.
|
|
1006
1015
|
*/
|
|
1007
1016
|
function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
|
|
1008
1017
|
const key = resourceKey('files', 'organisation', org.id);
|
|
@@ -1023,7 +1032,12 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
|
|
|
1023
1032
|
system: 'files',
|
|
1024
1033
|
kind: 'organisation',
|
|
1025
1034
|
key: org.id,
|
|
1026
|
-
desired: {
|
|
1035
|
+
desired: {
|
|
1036
|
+
provision: true,
|
|
1037
|
+
ceilingBytes: null,
|
|
1038
|
+
ceilingMicros: null,
|
|
1039
|
+
agoraOrigin: `https://${agoraHostnameFor(org)}`,
|
|
1040
|
+
},
|
|
1027
1041
|
},
|
|
1028
1042
|
step: {
|
|
1029
1043
|
description: `provision ${org.slug}'s own organisation on Files`,
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1114,6 +1114,20 @@ export interface FilesOrganisationPort {
|
|
|
1114
1114
|
name: string;
|
|
1115
1115
|
created: boolean;
|
|
1116
1116
|
}>;
|
|
1117
|
+
/**
|
|
1118
|
+
* #273: adds `origin` to the files host's `FILES_ALLOWED_ORIGINS`
|
|
1119
|
+
* allowlist (`wtfalch/files#109`) -- additively, never removing another
|
|
1120
|
+
* organisation's entry already there. That list is a Coolify env var on
|
|
1121
|
+
* the files runner app, not a database row the files management API
|
|
1122
|
+
* reports back (files#109's own reasoning: a database round trip on the
|
|
1123
|
+
* unauthenticated CORS preflight is both a hot-path cost and a new way to
|
|
1124
|
+
* fail closed), so there is nothing here to diff against a live value --
|
|
1125
|
+
* the host decides how `origin` actually reaches that env var, the same
|
|
1126
|
+
* way every other method on this port decides how to speak the service's
|
|
1127
|
+
* protocol. Safely repeatable: calling it again with an origin already on
|
|
1128
|
+
* the list is a no-op.
|
|
1129
|
+
*/
|
|
1130
|
+
ensureAllowedOrigin(organisationId: string, origin: string): Promise<void>;
|
|
1117
1131
|
}
|
|
1118
1132
|
/**
|
|
1119
1133
|
* Provisions one org's organisation on AI and switches its AI use on (#80),
|
|
@@ -2345,6 +2359,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2345
2359
|
* safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
|
|
2346
2360
|
* this is called every time regardless of whether it already ran --
|
|
2347
2361
|
* idempotent by find-or-create, not by checking `recordedIds` first.
|
|
2362
|
+
*
|
|
2363
|
+
* #273: also ensures the org's Agora origin is on the files host's CORS
|
|
2364
|
+
* allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
|
|
2365
|
+
* repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
|
|
2366
|
+
* gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
|
|
2367
|
+
* `desired.agoraOrigin`.
|
|
2348
2368
|
*/
|
|
2349
2369
|
private filesOrganisation;
|
|
2350
2370
|
/**
|
package/dist/plan/executor.js
CHANGED
|
@@ -3970,6 +3970,12 @@ export class FoundryStepExecutor {
|
|
|
3970
3970
|
* safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
|
|
3971
3971
|
* this is called every time regardless of whether it already ran --
|
|
3972
3972
|
* idempotent by find-or-create, not by checking `recordedIds` first.
|
|
3973
|
+
*
|
|
3974
|
+
* #273: also ensures the org's Agora origin is on the files host's CORS
|
|
3975
|
+
* allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
|
|
3976
|
+
* repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
|
|
3977
|
+
* gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
|
|
3978
|
+
* `desired.agoraOrigin`.
|
|
3973
3979
|
*/
|
|
3974
3980
|
async filesOrganisation(action, org, context) {
|
|
3975
3981
|
const files = await this.filesPort();
|
|
@@ -3985,6 +3991,9 @@ export class FoundryStepExecutor {
|
|
|
3985
3991
|
ceilingMicros: action.desired.ceilingMicros ?? null,
|
|
3986
3992
|
...(owner ? { name: org.displayName, owner } : {}),
|
|
3987
3993
|
});
|
|
3994
|
+
const agoraOrigin = action.desired.agoraOrigin;
|
|
3995
|
+
if (agoraOrigin)
|
|
3996
|
+
await files.ensureAllowedOrigin(org.id, agoraOrigin);
|
|
3988
3997
|
// Recorded now, so the org key is minted below the member files made.
|
|
3989
3998
|
if (owner && provisioned.created)
|
|
3990
3999
|
context.recordScopeId(FILES_ORG_OWNER_SCOPE_KEY, owner);
|
|
@@ -250,7 +250,8 @@ export declare const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = "foundry:platform:adm
|
|
|
250
250
|
* ({@link PlatformMailDeps.stalwartForInstance}), the same way
|
|
251
251
|
* `zitadelForInstance` is built from a freshly issued instance's issuer URL.
|
|
252
252
|
* {@link defaultStalwartForInstance} is the real implementation, wrapping
|
|
253
|
-
* `@wtfalch/email/postmaster`'s `openInstance`, `
|
|
253
|
+
* `@wtfalch/email/postmaster`'s `openInstance`, `Bootstrap/set` while the
|
|
254
|
+
* server is still in bootstrap mode, `applyObjects` with
|
|
254
255
|
* `platformMailSettingsObjects` (see `provisionPlatformMail`'s own doc
|
|
255
256
|
* comment for why not `mailObjects`) and `StalwartClient.createApiKey`.
|
|
256
257
|
*/
|
|
@@ -258,7 +259,10 @@ export interface PlatformMailAdminPort {
|
|
|
258
259
|
/**
|
|
259
260
|
* Sets the instance's default hostname and its OIDC directory
|
|
260
261
|
* (`x:SystemSettings`/`x:Directory`/`x:Domain`/`x:Authentication`) --
|
|
261
|
-
* idempotent, safe to call on every apply once the instance is up.
|
|
262
|
+
* idempotent, safe to call on every apply once the instance is up. A fresh
|
|
263
|
+
* instance is first given its `Bootstrap` config; Stalwart 0.16 loads that
|
|
264
|
+
* only on its next start, so the call then returns `'restart'` having
|
|
265
|
+
* written nothing else. The caller restarts the app and calls again.
|
|
262
266
|
*/
|
|
263
267
|
applySettings(input: {
|
|
264
268
|
mailHost: string;
|
|
@@ -268,8 +272,12 @@ export interface PlatformMailAdminPort {
|
|
|
268
272
|
issuerUrl: string;
|
|
269
273
|
audience: string;
|
|
270
274
|
};
|
|
271
|
-
}): Promise<
|
|
272
|
-
/**
|
|
275
|
+
}): Promise<'restart' | undefined>;
|
|
276
|
+
/**
|
|
277
|
+
* `StalwartClient.createApiKey`'s own signature. The key belongs to a real
|
|
278
|
+
* admin account, not to the recovery admin (see
|
|
279
|
+
* {@link defaultStalwartForInstance}).
|
|
280
|
+
*/
|
|
273
281
|
createApiKey(description: string, permissions: readonly string[] | 'inherit'): Promise<string>;
|
|
274
282
|
}
|
|
275
283
|
/**
|
|
@@ -291,6 +299,8 @@ export declare function defaultStalwartForInstance(input: {
|
|
|
291
299
|
url: string;
|
|
292
300
|
user: string;
|
|
293
301
|
password: string;
|
|
302
|
+
/** The platform's apex: the domain of the admin account the API key is minted as. */
|
|
303
|
+
apex: string;
|
|
294
304
|
}): PlatformMailAdminPort;
|
|
295
305
|
/**
|
|
296
306
|
* #169: everything {@link provisionPlatformMail} needs beyond
|
|
@@ -320,6 +330,7 @@ export interface PlatformMailDeps {
|
|
|
320
330
|
url: string;
|
|
321
331
|
user: string;
|
|
322
332
|
password: string;
|
|
333
|
+
apex: string;
|
|
323
334
|
}): PlatformMailAdminPort;
|
|
324
335
|
deliverSecret: FoundryStepExecutorPorts['deliverSecret'];
|
|
325
336
|
/** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
|
|
@@ -1413,7 +1413,20 @@ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join
|
|
|
1413
1413
|
* ZITADEL/login apps.
|
|
1414
1414
|
*/
|
|
1415
1415
|
const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
|
|
1416
|
-
|
|
1416
|
+
/**
|
|
1417
|
+
* The v0.16 image's two VOLUMEs (its Dockerfile): the data store, and the
|
|
1418
|
+
* `config.json` Stalwart writes when bootstrap completes. The pre-0.16 image
|
|
1419
|
+
* used `/opt/stalwart`, which v0.16 does not use. Each is a Coolify
|
|
1420
|
+
* persistent storage (a Docker named volume, not a host path), so Docker
|
|
1421
|
+
* seeds it from the image on first use and keeps the image's UID 2000
|
|
1422
|
+
* ownership, which the container needs to write it.
|
|
1423
|
+
*/
|
|
1424
|
+
// Suffixes differ from the pre-0.16 storage's `-data` (still attached at
|
|
1425
|
+
// /opt/stalwart on apps created before), so a new storage never shares its name.
|
|
1426
|
+
const STALWART_STORAGES = [
|
|
1427
|
+
{ suffix: 'var-lib', mount_path: '/var/lib/stalwart' },
|
|
1428
|
+
{ suffix: 'etc', mount_path: '/etc/stalwart' },
|
|
1429
|
+
];
|
|
1417
1430
|
/** `STALWART_RECOVERY_ADMIN=<user>:<password>` -- the fixed recovery account name; only the password half is generated. */
|
|
1418
1431
|
const STALWART_RECOVERY_USER = 'admin';
|
|
1419
1432
|
/**
|
|
@@ -1474,11 +1487,58 @@ export function defaultStalwartForInstance(input) {
|
|
|
1474
1487
|
});
|
|
1475
1488
|
return {
|
|
1476
1489
|
async applySettings({ mailHost, apex, oidc }) {
|
|
1490
|
+
// A fresh Stalwart (0.16) is in bootstrap mode and refuses every write
|
|
1491
|
+
// but the `Bootstrap` object, and loads that object only on its next
|
|
1492
|
+
// start, so write it and hand the restart to the caller. A server
|
|
1493
|
+
// already past it skips this. `update` drops the response, which
|
|
1494
|
+
// carries the generated `admin@<apex>` secret: this step signs in with
|
|
1495
|
+
// the recovery admin and never needs it.
|
|
1496
|
+
if ((await instance.client.probe()) === 'bootstrap') {
|
|
1497
|
+
await instance.client.update('Bootstrap', 'singleton', {
|
|
1498
|
+
serverHostname: mailHost,
|
|
1499
|
+
defaultDomain: apex,
|
|
1500
|
+
// The server sits behind a tunnel; no ACME challenge can reach it.
|
|
1501
|
+
requestTlsCertificate: false,
|
|
1502
|
+
});
|
|
1503
|
+
return 'restart';
|
|
1504
|
+
}
|
|
1477
1505
|
await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
|
|
1478
1506
|
},
|
|
1479
|
-
createApiKey
|
|
1507
|
+
async createApiKey(description, permissions) {
|
|
1508
|
+
// The recovery admin is not a directory account (Stalwart's
|
|
1509
|
+
// `account_set` in `jmap/src/registry/mapping/account.rs` finds no
|
|
1510
|
+
// Account for its id and answers notFound), so it cannot own a key. Give an internal admin account (no credential, so
|
|
1511
|
+
// nothing to sign in with, and the OIDC directory is never asked) and
|
|
1512
|
+
// act as it: the recovery admin may impersonate any account as
|
|
1513
|
+
// `<account>%<recovery user>`.
|
|
1514
|
+
const domains = await instance.client.list('Domain');
|
|
1515
|
+
const domain = domains.find((d) => d.name === input.apex);
|
|
1516
|
+
if (!domain) {
|
|
1517
|
+
throw new Error(`platform:mail: Stalwart has no domain ${input.apex} to hold the admin account`);
|
|
1518
|
+
}
|
|
1519
|
+
await instance.client.upsert('Account', (a) => a.name === PLATFORM_MAIL_ADMIN_ACCOUNT && a.domainId === domain.id, {
|
|
1520
|
+
'@type': 'User',
|
|
1521
|
+
name: PLATFORM_MAIL_ADMIN_ACCOUNT,
|
|
1522
|
+
domainId: domain.id,
|
|
1523
|
+
description: 'foundry platform admin (API key owner)',
|
|
1524
|
+
roles: { '@type': 'Admin' },
|
|
1525
|
+
permissions: { '@type': 'Inherit' },
|
|
1526
|
+
encryptionAtRest: { '@type': 'Disabled' },
|
|
1527
|
+
quotas: {},
|
|
1528
|
+
aliases: {},
|
|
1529
|
+
memberGroupIds: {},
|
|
1530
|
+
}, {});
|
|
1531
|
+
const asAdmin = openInstance({
|
|
1532
|
+
url: input.url,
|
|
1533
|
+
secret: input.password,
|
|
1534
|
+
user: `${PLATFORM_MAIL_ADMIN_ACCOUNT}@${input.apex}%${input.user}`,
|
|
1535
|
+
});
|
|
1536
|
+
return asAdmin.client.createApiKey(description, permissions);
|
|
1537
|
+
},
|
|
1480
1538
|
};
|
|
1481
1539
|
}
|
|
1540
|
+
/** The internal account the admin API key belongs to, in the platform's apex domain. */
|
|
1541
|
+
const PLATFORM_MAIL_ADMIN_ACCOUNT = 'foundry-admin';
|
|
1482
1542
|
/** The ZITADEL project and resource api Stalwart's own OIDC directory checks access tokens' `aud` claim against (#346). */
|
|
1483
1543
|
const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
|
|
1484
1544
|
/**
|
|
@@ -1608,12 +1668,19 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1608
1668
|
await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`);
|
|
1609
1669
|
// ---- storage ---------------------------------------------------------------
|
|
1610
1670
|
const storages = await deps.coolify.listStorages(appId);
|
|
1611
|
-
|
|
1612
|
-
|
|
1671
|
+
// An app made before this release only has the old `/opt/stalwart` volume.
|
|
1672
|
+
// That one is left in place (empty and unused; detaching it needs a call this
|
|
1673
|
+
// step has no port for) and the two real ones are added.
|
|
1674
|
+
let storagesAdded = false;
|
|
1675
|
+
for (const wanted of STALWART_STORAGES) {
|
|
1676
|
+
if ((storages.persistent_storages ?? []).some((st) => st.mount_path === wanted.mount_path)) {
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1613
1679
|
await deps.coolify.createPersistentStorage(appId, {
|
|
1614
|
-
name: `${PLATFORM_MAIL_APP_NAME}
|
|
1615
|
-
mount_path:
|
|
1680
|
+
name: `${PLATFORM_MAIL_APP_NAME}-${wanted.suffix}`,
|
|
1681
|
+
mount_path: wanted.mount_path,
|
|
1616
1682
|
});
|
|
1683
|
+
storagesAdded = true;
|
|
1617
1684
|
}
|
|
1618
1685
|
// ---- env ---------------------------------------------------------------------
|
|
1619
1686
|
// Generated once; unlike ZITADEL's masterkey this is never re-derived from
|
|
@@ -1630,7 +1697,8 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1630
1697
|
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
1631
1698
|
const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
1632
1699
|
recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
|
|
1633
|
-
|
|
1700
|
+
// A new storage binds into the container only at its next start.
|
|
1701
|
+
if (app.justCreated || !app.running || envsChanged || declaredChanged || storagesAdded) {
|
|
1634
1702
|
// A container still up from before would answer the readiness probe with
|
|
1635
1703
|
// the old deployment's health. Stop it and see it stopped first, so the
|
|
1636
1704
|
// wait below only ever reads the new deployment.
|
|
@@ -1748,11 +1816,19 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1748
1816
|
url: `https://${hostname}`,
|
|
1749
1817
|
user: STALWART_RECOVERY_USER,
|
|
1750
1818
|
password,
|
|
1751
|
-
});
|
|
1752
|
-
await admin.applySettings({
|
|
1753
|
-
mailHost,
|
|
1754
1819
|
apex: platform.apex,
|
|
1755
|
-
|
|
1820
|
+
});
|
|
1821
|
+
await applyMailSettings(admin, {
|
|
1822
|
+
coolify: deps.coolify,
|
|
1823
|
+
appId,
|
|
1824
|
+
runtime,
|
|
1825
|
+
sleep,
|
|
1826
|
+
hostname,
|
|
1827
|
+
settings: {
|
|
1828
|
+
mailHost,
|
|
1829
|
+
apex: platform.apex,
|
|
1830
|
+
oidc: { issuerUrl: issuer, audience: project.id },
|
|
1831
|
+
},
|
|
1756
1832
|
});
|
|
1757
1833
|
await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
1758
1834
|
}
|
|
@@ -1780,11 +1856,19 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1780
1856
|
url: `https://${hostname}`,
|
|
1781
1857
|
user: STALWART_RECOVERY_USER,
|
|
1782
1858
|
password,
|
|
1783
|
-
});
|
|
1784
|
-
await admin.applySettings({
|
|
1785
|
-
mailHost,
|
|
1786
1859
|
apex: platform.apex,
|
|
1787
|
-
|
|
1860
|
+
});
|
|
1861
|
+
await applyMailSettings(admin, {
|
|
1862
|
+
coolify: deps.coolify,
|
|
1863
|
+
appId,
|
|
1864
|
+
runtime,
|
|
1865
|
+
sleep,
|
|
1866
|
+
hostname,
|
|
1867
|
+
settings: {
|
|
1868
|
+
mailHost,
|
|
1869
|
+
apex: platform.apex,
|
|
1870
|
+
oidc: { issuerUrl: issuer, audience: project.id },
|
|
1871
|
+
},
|
|
1788
1872
|
});
|
|
1789
1873
|
// Recorded and persisted before the secret leaves this function: if
|
|
1790
1874
|
// delivery fails for good, the next plan reports the lost key rather
|
|
@@ -1982,6 +2066,37 @@ async function waitForMailInstance(url, runtime, sleep) {
|
|
|
1982
2066
|
await sleep(interval);
|
|
1983
2067
|
}
|
|
1984
2068
|
}
|
|
2069
|
+
/**
|
|
2070
|
+
* Applies the settings; when the instance was fresh, restarts the app (the
|
|
2071
|
+
* `Bootstrap` config loads only on Stalwart's next start) and applies again
|
|
2072
|
+
* once it answers ready. Rejects if it is still in bootstrap after the wait.
|
|
2073
|
+
*/
|
|
2074
|
+
async function applyMailSettings(admin, input) {
|
|
2075
|
+
const { runtime, sleep } = input;
|
|
2076
|
+
if ((await admin.applySettings(input.settings)) !== 'restart')
|
|
2077
|
+
return;
|
|
2078
|
+
await input.coolify.raw('POST', `/applications/${input.appId}/restart`);
|
|
2079
|
+
const now = runtime.now ?? Date.now;
|
|
2080
|
+
const deadline = now() + (runtime.readyTimeoutMs ?? 300_000);
|
|
2081
|
+
for (;;) {
|
|
2082
|
+
await sleep(runtime.readyIntervalMs ?? 5_000);
|
|
2083
|
+
await waitForMailInstance(`https://${input.hostname}${STALWART_READY_PATH}`, runtime, sleep);
|
|
2084
|
+
// The old container may still answer, or drop the connection, for a
|
|
2085
|
+
// moment after the restart call.
|
|
2086
|
+
try {
|
|
2087
|
+
if ((await admin.applySettings(input.settings)) !== 'restart')
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
catch (error) {
|
|
2091
|
+
if (now() >= deadline)
|
|
2092
|
+
throw error;
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
if (now() >= deadline) {
|
|
2096
|
+
throw new Error(`platform:mail: Stalwart at https://${input.hostname} is still in bootstrap mode after a restart; apply again`);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
1985
2100
|
function redactedMailError(error, secrets) {
|
|
1986
2101
|
const original = error instanceof Error ? error : new Error(String(error));
|
|
1987
2102
|
const message = redactSecrets(original.message, secrets);
|