@wtfalch/foundry 0.34.0 → 0.35.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/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +20 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.js +3 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +60 -14
- package/dist/org.js +118 -28
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.js +3 -3
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +17 -8
- package/dist/plan/diff.js +131 -131
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +166 -7
- package/dist/plan/executor.js +547 -106
- package/dist/plan/health-targets.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +5 -0
- package/dist/plan/held-key-refs.js +5 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +5 -0
- package/dist/plan/index.js +3 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +3 -2
- package/dist/plan/locked-hostnames.d.ts +2 -1
- package/dist/plan/locked-hostnames.js +16 -3
- package/dist/plan/login-host.d.ts +9 -20
- package/dist/plan/login-host.js +10 -25
- package/dist/plan/mail-relay.d.ts +48 -29
- package/dist/plan/mail-relay.js +145 -68
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +8 -2
- package/dist/plan/org-names.js +57 -1
- package/dist/plan/own-diff.d.ts +9 -8
- package/dist/plan/own-diff.js +42 -34
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +198 -7
- package/dist/plan/own-limits.js +163 -32
- package/dist/plan/own-services.js +3 -1
- package/dist/plan/own-sfu.js +3 -2
- package/dist/plan/plan-apply.d.ts +47 -7
- package/dist/plan/plan-apply.js +184 -38
- package/dist/plan/platform-diff.d.ts +133 -17
- package/dist/plan/platform-diff.js +231 -68
- package/dist/plan/platform-org.d.ts +79 -10
- package/dist/plan/platform-org.js +93 -13
- package/dist/plan/platform-providers.d.ts +118 -73
- package/dist/plan/platform-providers.js +770 -231
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +76 -8
- package/dist/platform.d.ts +32 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +25 -3
- package/dist/store/postgres.js +43 -1
- package/dist/store/schema.d.ts +36 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
package/dist/org.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { floatingTagBlocked } from './bootstrap/versions.js';
|
|
1
|
+
import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
|
|
2
2
|
/** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
|
|
3
3
|
export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
|
|
4
4
|
/** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
|
|
@@ -41,24 +41,18 @@ export function appEntry(hosting) {
|
|
|
41
41
|
return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
|
|
48
|
-
* (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
|
|
44
|
+
* `sharedSecret` is the one secret source: {@link EnvSharedSecret}. The
|
|
45
|
+
* platform's Lettermint key is not a source any more (2026-10-11): each
|
|
46
|
+
* company uses its own mail key.
|
|
49
47
|
*/
|
|
50
48
|
export const APP_ENV_SOURCES = new Set([
|
|
51
49
|
'filesOrganisationId',
|
|
52
50
|
'aiOrganisationId',
|
|
53
51
|
'mailDomain',
|
|
54
52
|
'sharedSecret',
|
|
55
|
-
'platformMailKey',
|
|
56
53
|
]);
|
|
57
54
|
/** The sources whose value is a secret: resolved at apply time, never recorded. */
|
|
58
|
-
export const SECRET_APP_ENV_SOURCES = new Set([
|
|
59
|
-
'sharedSecret',
|
|
60
|
-
'platformMailKey',
|
|
61
|
-
]);
|
|
55
|
+
export const SECRET_APP_ENV_SOURCES = new Set(['sharedSecret']);
|
|
62
56
|
/** The held-key ref of an org's shared secret. */
|
|
63
57
|
export const sharedSecretRef = (orgId, name) => `foundry:shared-secret:${orgId}:${name}`;
|
|
64
58
|
const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
|
|
@@ -336,6 +330,11 @@ function validateHostingEntry(entry, enabled) {
|
|
|
336
330
|
if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
|
|
337
331
|
return `${at} healthPath must start with '/', or be null`;
|
|
338
332
|
}
|
|
333
|
+
if (entry.capabilities !== undefined &&
|
|
334
|
+
entry.capabilities !== 'restricted' &&
|
|
335
|
+
entry.capabilities !== 'standard') {
|
|
336
|
+
return `${at} capabilities must be 'restricted' or 'standard'`;
|
|
337
|
+
}
|
|
339
338
|
if (entry.database && !enabled) {
|
|
340
339
|
return `${at} database requires hosting.enabled`;
|
|
341
340
|
}
|
|
@@ -347,6 +346,9 @@ function validateHostingEntry(entry, enabled) {
|
|
|
347
346
|
return `${at} env '${name}' is set by foundry and cannot be declared`;
|
|
348
347
|
}
|
|
349
348
|
if (typeof value !== 'string') {
|
|
349
|
+
if (value?.from === 'platformMailKey') {
|
|
350
|
+
return `${at} env '${name}' has an unknown source 'platformMailKey': each company uses its own mail key`;
|
|
351
|
+
}
|
|
350
352
|
if (!APP_ENV_SOURCES.has(value?.from)) {
|
|
351
353
|
return `${at} env '${name}' has an unknown source '${value?.from}'`;
|
|
352
354
|
}
|
|
@@ -387,6 +389,10 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
387
389
|
(typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
|
|
388
390
|
return `${at} replacesBrokerEntry must be the non-empty name of a broker entry`;
|
|
389
391
|
}
|
|
392
|
+
if (typeof auth.replacesBrokerEntry === 'string' &&
|
|
393
|
+
isFoundryOwnedBrokerEntryName(auth.replacesBrokerEntry)) {
|
|
394
|
+
return `${at} replacesBrokerEntry '${auth.replacesBrokerEntry}' is a broker entry foundry writes itself`;
|
|
395
|
+
}
|
|
390
396
|
if (!hasExactHostname(entry, hosting, domain)) {
|
|
391
397
|
return `${at} needs an exact hostname to sign in at`;
|
|
392
398
|
}
|
|
@@ -627,6 +633,9 @@ function urlHasPassword(value) {
|
|
|
627
633
|
* as {@link validateBuyerIdentity}/{@link validateOwners}.
|
|
628
634
|
*/
|
|
629
635
|
const DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
636
|
+
const SHARED_ZONE = 'oikos.host';
|
|
637
|
+
/** The mail relay accepts only a letters-only top-level label (`plan/mail-relay.ts`); one domain it rejects would block the relay step for every company. */
|
|
638
|
+
const RELAY_TLD = /^[a-z]{2,63}$/;
|
|
630
639
|
export function validateDomain(domain) {
|
|
631
640
|
if (domain.length > 253) {
|
|
632
641
|
return `domain '${domain}' exceeds 253 characters`;
|
|
@@ -640,6 +649,45 @@ export function validateDomain(domain) {
|
|
|
640
649
|
return `domain '${domain}' has an invalid label '${label}': must be 1-63 characters, lowercase a-z, 0-9 and hyphen, and may not start or end with a hyphen`;
|
|
641
650
|
}
|
|
642
651
|
}
|
|
652
|
+
if (!RELAY_TLD.test(labels[labels.length - 1] ?? '')) {
|
|
653
|
+
return `domain '${domain}' must end in a top-level label of 2 to 63 letters`;
|
|
654
|
+
}
|
|
655
|
+
// oikos.host is the shared zone every company without a domain runs on; no company owns it or a name under it.
|
|
656
|
+
if (domain === SHARED_ZONE || domain.endsWith(`.${SHARED_ZONE}`)) {
|
|
657
|
+
return `domain '${domain}' is on ${SHARED_ZONE}, the shared zone for companies without a domain; a company cannot own it`;
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* A broker entry name foundry writes itself: Archon's, and any company's Boule.
|
|
663
|
+
* A hosting entry may not declare that it replaces one.
|
|
664
|
+
*/
|
|
665
|
+
export function isFoundryOwnedBrokerEntryName(name) {
|
|
666
|
+
return name === 'archon' || name.endsWith('-boule');
|
|
667
|
+
}
|
|
668
|
+
/** True when one name is the other or a subdomain of the other. */
|
|
669
|
+
function domainsOverlap(a, b) {
|
|
670
|
+
return a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* `null` when `domain` may be claimed by company `orgId`, given the platform's
|
|
674
|
+
* apex and owner company and every other company's declared domain; otherwise
|
|
675
|
+
* the reason it may not. The platform's apex and anything under it belong to
|
|
676
|
+
* the owner company alone, and no company takes a parent or subdomain of
|
|
677
|
+
* another's, since Cloudflare would resolve both to one zone.
|
|
678
|
+
*/
|
|
679
|
+
export function validateDomainClaim(domain, claim) {
|
|
680
|
+
const { platform } = claim;
|
|
681
|
+
if (platform && claim.orgId !== platform.ownerOrgId && domainsOverlap(domain, platform.apex)) {
|
|
682
|
+
return `domain '${domain}' overlaps the platform's own domain, which only the owner company may use`;
|
|
683
|
+
}
|
|
684
|
+
for (const other of claim.others) {
|
|
685
|
+
if (other.id === claim.orgId || other.domain === null)
|
|
686
|
+
continue;
|
|
687
|
+
if (domainsOverlap(domain, other.domain)) {
|
|
688
|
+
return `domain '${domain}' is a parent or subdomain of ${other.domain}, which another company declares`;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
643
691
|
return null;
|
|
644
692
|
}
|
|
645
693
|
/**
|
|
@@ -652,11 +700,9 @@ export function validateDomain(domain) {
|
|
|
652
700
|
* and cms there (`site`), never both -- both would otherwise fight over the
|
|
653
701
|
* same hostname (https://github.com/wtfalch/foundry/issues/99).
|
|
654
702
|
*
|
|
655
|
-
* - `site.enabled`
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
* and the issuer, the site has no meaning anywhere but the org's own
|
|
659
|
-
* domain, so turning it on always means the domain opt-in too.
|
|
703
|
+
* - `site.enabled` with a `domain` requires `site.usesDomain`: the site then
|
|
704
|
+
* lives on the domain. Without a `domain` it lives on `<slug>.oikos.host`
|
|
705
|
+
* and `site.usesDomain` must be off.
|
|
660
706
|
* - `site.enabled` and `hosting.enabled` cannot both be on.
|
|
661
707
|
* - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
|
|
662
708
|
* client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
|
|
@@ -666,12 +712,14 @@ export function validateDomain(domain) {
|
|
|
666
712
|
* to.
|
|
667
713
|
*/
|
|
668
714
|
export function validateSite(org) {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (org.site.enabled && !org.site.usesDomain) {
|
|
715
|
+
// A company without a domain has its site at `<slug>.oikos.host` and its editor
|
|
716
|
+
// at `<slug>-cms.oikos.host`; `site.usesDomain` is then off, and on with a domain.
|
|
717
|
+
if (org.site.enabled && org.domain && !org.site.usesDomain) {
|
|
673
718
|
return 'site.enabled requires site.usesDomain';
|
|
674
719
|
}
|
|
720
|
+
if (org.site.enabled && !org.domain && org.site.usesDomain) {
|
|
721
|
+
return 'site.usesDomain requires domain';
|
|
722
|
+
}
|
|
675
723
|
if (org.site.enabled && org.hosting.enabled) {
|
|
676
724
|
return 'site.enabled and hosting.enabled cannot both be on';
|
|
677
725
|
}
|
|
@@ -691,6 +739,9 @@ export function validateSite(org) {
|
|
|
691
739
|
* reason to pin at all.
|
|
692
740
|
*/
|
|
693
741
|
export function validateCalls(org) {
|
|
742
|
+
// `usesDomain` serves the calls service at `calls.<domain>`; a company without a domain has none.
|
|
743
|
+
if (org.calls.usesDomain && org.domain === null)
|
|
744
|
+
return 'calls.usesDomain requires domain';
|
|
694
745
|
const pin = org.calls.versionPin;
|
|
695
746
|
if (!pin)
|
|
696
747
|
return null;
|
|
@@ -706,8 +757,49 @@ export function validateCalls(org) {
|
|
|
706
757
|
}
|
|
707
758
|
return null;
|
|
708
759
|
}
|
|
760
|
+
/** The parts of a company that can be held on their own version. */
|
|
761
|
+
export const VERSION_PIN_PARTS = ['auth', 'mail', 'files', 'jobs', 'sfu', 'calls'];
|
|
762
|
+
const VERSION_PIN_LABELS = {
|
|
763
|
+
auth: { label: 'auth', example: 'v1.4.0', gitRef: true },
|
|
764
|
+
mail: { label: 'Stalwart', example: 'v0.16.24', gitRef: false },
|
|
765
|
+
files: { label: 'files', example: 'v1.4.0', gitRef: true },
|
|
766
|
+
jobs: { label: 'jobs', example: 'v1.4.0', gitRef: true },
|
|
767
|
+
sfu: { label: 'LiveKit', example: 'v1.9.0', gitRef: false },
|
|
768
|
+
calls: { label: 'Synapse', example: 'v1.161.0', gitRef: false },
|
|
769
|
+
};
|
|
770
|
+
const VERSION_PIN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
|
|
771
|
+
/**
|
|
772
|
+
* `versionPin` holds parts of a company on an older release while others move.
|
|
773
|
+
* Every value is a fixed ref or tag: a floating one would defeat the hold.
|
|
774
|
+
* Also run on a stored value when it is read, so a bad row is not trusted.
|
|
775
|
+
*/
|
|
776
|
+
export function validateVersionPin(org) {
|
|
777
|
+
const pin = org.versionPin;
|
|
778
|
+
if (pin === undefined || pin === null)
|
|
779
|
+
return null;
|
|
780
|
+
if (typeof pin !== 'object' || Array.isArray(pin))
|
|
781
|
+
return 'versionPin must be an object';
|
|
782
|
+
for (const [part, value] of Object.entries(pin)) {
|
|
783
|
+
if (!VERSION_PIN_PARTS.includes(part)) {
|
|
784
|
+
return `versionPin.${part} is not a part; use auth, mail, files, jobs, sfu or calls`;
|
|
785
|
+
}
|
|
786
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
787
|
+
return `versionPin.${part} must not be empty; remove the part to follow the platform`;
|
|
788
|
+
}
|
|
789
|
+
const meta = VERSION_PIN_LABELS[part];
|
|
790
|
+
const blocked = meta.gitRef
|
|
791
|
+
? floatingRefBlocked(meta.label, value, meta.example)
|
|
792
|
+
: floatingTagBlocked(meta.label, value, meta.example);
|
|
793
|
+
if (blocked)
|
|
794
|
+
return `versionPin.${part} ${blocked}`;
|
|
795
|
+
if (!VERSION_PIN_VALUE.test(value) || value.includes('..')) {
|
|
796
|
+
return `versionPin.${part} must be a release name of letters, digits, ".", "_", "+" and "-" (at most 128 characters, no ".."); it goes to Coolify as a git ref or an image tag`;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
709
801
|
export const SFU_SLOT_MAX = 20;
|
|
710
|
-
/** The only reader of `services.own
|
|
802
|
+
/** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
|
|
711
803
|
export function ownsServices(org) {
|
|
712
804
|
return org.services?.own === true;
|
|
713
805
|
}
|
|
@@ -717,14 +809,12 @@ export function validateServices(org) {
|
|
|
717
809
|
if (!ownsServices(org)) {
|
|
718
810
|
return slot === null ? null : 'services.sfuSlot needs services.own';
|
|
719
811
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
if (!org.issuer.usesDomain) {
|
|
724
|
-
return 'services.own needs issuer.usesDomain: the issuer is auth.<domain>';
|
|
812
|
+
const hasDomain = org.domain !== null;
|
|
813
|
+
if (org.issuer.usesDomain !== hasDomain) {
|
|
814
|
+
return 'services.own: issuer.usesDomain must be on exactly when the company has a domain';
|
|
725
815
|
}
|
|
726
|
-
if (org.mail.mode !== 'off' &&
|
|
727
|
-
return
|
|
816
|
+
if (org.mail.mode !== 'off' && org.mail.usesDomain !== hasDomain) {
|
|
817
|
+
return 'services.own: mail.usesDomain must be on exactly when the company has a domain';
|
|
728
818
|
}
|
|
729
819
|
if (org.calls.enabled &&
|
|
730
820
|
(slot === null || !Number.isInteger(slot) || slot < 1 || slot > SFU_SLOT_MAX)) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { OrgId } from '../org.js';
|
|
2
|
+
import type { VersionChangeItem } from '../plan.js';
|
|
3
|
+
import { type CoolifyPort } from './executor.js';
|
|
4
|
+
/** A confirmed stateful upgrade needs a dump that finished within this many hours. */
|
|
5
|
+
export declare const BACKUP_MAX_AGE_HOURS = 26;
|
|
6
|
+
/** A move deletes the databases, so the dump it relies on is at most this old. */
|
|
7
|
+
export declare const MOVE_BACKUP_MAX_AGE_MINUTES = 60;
|
|
8
|
+
/**
|
|
9
|
+
* Thrown before any step runs when a confirmed upgrade of a stateful service
|
|
10
|
+
* has no fresh finished backup. The message names the service and nothing
|
|
11
|
+
* else: no database name, uuid or URL. There is no override.
|
|
12
|
+
*/
|
|
13
|
+
export declare class UpgradeBackupMissingError extends Error {
|
|
14
|
+
readonly service: string;
|
|
15
|
+
/** True when this Coolify has no executions route, so only the schedule was checked. */
|
|
16
|
+
readonly scheduleOnly: boolean;
|
|
17
|
+
constructor(service: string,
|
|
18
|
+
/** True when this Coolify has no executions route, so only the schedule was checked. */
|
|
19
|
+
scheduleOnly?: boolean);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A network move is refused when the host has no backup gate, when a database of the project has no
|
|
23
|
+
* verified dump under {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes old, or when the project holds
|
|
24
|
+
* something no dump covers (the company's mail server, an app with a volume) or cannot be read. The
|
|
25
|
+
* message names no database, app, uuid or URL. There is no override: such a company is moved by hand.
|
|
26
|
+
*/
|
|
27
|
+
export declare class NetworkMoveBackupMissingError extends Error {
|
|
28
|
+
readonly reason: 'no-gate' | 'no-backup' | 'by-hand';
|
|
29
|
+
constructor(reason: 'no-gate' | 'no-backup' | 'by-hand');
|
|
30
|
+
}
|
|
31
|
+
export interface BackupGatePorts {
|
|
32
|
+
/** The company's recorded ids (`AppliedState.serviceIds`): database uuids and `backup_<db>` schedule uuids. */
|
|
33
|
+
recordedIds: Readonly<Record<string, string>>;
|
|
34
|
+
coolify: Pick<CoolifyPort, 'listDatabaseBackups' | 'raw'> & Partial<Pick<CoolifyPort, 'listDatabaseBackupExecutions'>>;
|
|
35
|
+
/** The org's id, for the `app` database key. Unused when `scope` is given. */
|
|
36
|
+
orgId: OrgId;
|
|
37
|
+
/**
|
|
38
|
+
* The platform's databases instead of a company's: the recorded-id key of
|
|
39
|
+
* each database, and which of them an upgrade of a service migrates. The
|
|
40
|
+
* platform's backup steps record no schedule uuid, so the schedule is found
|
|
41
|
+
* by listing the database's schedules (enabled, saved to S3).
|
|
42
|
+
*/
|
|
43
|
+
scope?: {
|
|
44
|
+
keys: Readonly<Record<string, string>>;
|
|
45
|
+
forService(service: string): readonly string[] | 'all';
|
|
46
|
+
};
|
|
47
|
+
now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Refuses a confirmed upgrade of a stateful service whose database has no
|
|
51
|
+
* finished backup in the last {@link BACKUP_MAX_AGE_HOURS} hours. Runs after
|
|
52
|
+
* `unconfirmedUpgrades` and before any step. A service whose database the
|
|
53
|
+
* company does not have passes (there is nothing to migrate); a database that
|
|
54
|
+
* exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
|
|
55
|
+
* old dump, or an unreadable list all refuse. `mail` has no Postgres and is
|
|
56
|
+
* not covered here (plan note `backup-not-covered:mail`).
|
|
57
|
+
*
|
|
58
|
+
* Where this Coolify has no executions route, the fallback is that the
|
|
59
|
+
* schedule exists, is enabled and saves to S3; that proves a dump is
|
|
60
|
+
* scheduled, not that one ran, so it passes with no backup id.
|
|
61
|
+
*
|
|
62
|
+
* Returns the uuid of the newest fresh execution across the checked
|
|
63
|
+
* databases, for the caller to record as `backupId`, or `null`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function requireFreshBackupsForUpgrades(plan: {
|
|
66
|
+
versionChanges?: readonly VersionChangeItem[];
|
|
67
|
+
}, confirmed: readonly string[] | undefined, ports: BackupGatePorts): Promise<{
|
|
68
|
+
backupId: string | null;
|
|
69
|
+
}>;
|
|
70
|
+
export interface MoveGatePorts {
|
|
71
|
+
/** The company's Coolify project (`coolify_project`). */
|
|
72
|
+
projectUuid: string | undefined;
|
|
73
|
+
/** The company runs its own mail server: its volume has no dump. */
|
|
74
|
+
ownsMail: boolean;
|
|
75
|
+
coolify: BackupGatePorts['coolify'] & Partial<Pick<CoolifyPort, 'listResources' | 'listStorages'>>;
|
|
76
|
+
now?: () => number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The gate for a move to the company's own network, which deletes every database of the project. It
|
|
80
|
+
* fails closed and is stricter than the upgrade gate: it reads the project itself, not foundry's records.
|
|
81
|
+
* It passes only when every database in the project has a successful dump, seen in Coolify's
|
|
82
|
+
* executions, from the last {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes, and the project holds nothing
|
|
83
|
+
* a dump does not cover. It refuses (`by-hand`) the company's own mail server and any app with a
|
|
84
|
+
* volume other than its log volume, and (`no-backup`) a database with no schedule or no execution, an
|
|
85
|
+
* unreadable list, and a Coolify with no executions route. A company with neither database nor volume passes.
|
|
86
|
+
*/
|
|
87
|
+
export declare function requireFreshBackupsForMove(ports: MoveGatePorts): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
|
|
90
|
+
* integrations each migrate their own database when the new container starts. The owner company runs its own
|
|
91
|
+
* sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
|
|
92
|
+
*/
|
|
93
|
+
export declare function platformDatabasesForService(service: string): readonly string[] | 'all';
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { backupScopeKey, companyDatabaseKeys, databasesForService } from './company-backups.js';
|
|
2
|
+
import { LOG_DIR } from './executor.js';
|
|
3
|
+
/** A confirmed stateful upgrade needs a dump that finished within this many hours. */
|
|
4
|
+
export const BACKUP_MAX_AGE_HOURS = 26;
|
|
5
|
+
/** A move deletes the databases, so the dump it relies on is at most this old. */
|
|
6
|
+
export const MOVE_BACKUP_MAX_AGE_MINUTES = 60;
|
|
7
|
+
/** How long one Coolify call of the gate may take. */
|
|
8
|
+
const GATE_CALL_TIMEOUT_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* Thrown before any step runs when a confirmed upgrade of a stateful service
|
|
11
|
+
* has no fresh finished backup. The message names the service and nothing
|
|
12
|
+
* else: no database name, uuid or URL. There is no override.
|
|
13
|
+
*/
|
|
14
|
+
export class UpgradeBackupMissingError extends Error {
|
|
15
|
+
service;
|
|
16
|
+
scheduleOnly;
|
|
17
|
+
constructor(service,
|
|
18
|
+
/** True when this Coolify has no executions route, so only the schedule was checked. */
|
|
19
|
+
scheduleOnly = false) {
|
|
20
|
+
const note = scheduleOnly
|
|
21
|
+
? ' (this Coolify has no executions route, so only the schedule was checked, and it is missing, disabled or not saved to S3)'
|
|
22
|
+
: '';
|
|
23
|
+
super(`upgrade of ${service} refused: no finished backup of its database in the last ${BACKUP_MAX_AGE_HOURS} hours${note}`);
|
|
24
|
+
this.service = service;
|
|
25
|
+
this.scheduleOnly = scheduleOnly;
|
|
26
|
+
this.name = 'UpgradeBackupMissingError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A network move is refused when the host has no backup gate, when a database of the project has no
|
|
31
|
+
* verified dump under {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes old, or when the project holds
|
|
32
|
+
* something no dump covers (the company's mail server, an app with a volume) or cannot be read. The
|
|
33
|
+
* message names no database, app, uuid or URL. There is no override: such a company is moved by hand.
|
|
34
|
+
*/
|
|
35
|
+
export class NetworkMoveBackupMissingError extends Error {
|
|
36
|
+
reason;
|
|
37
|
+
constructor(reason) {
|
|
38
|
+
super(reason === 'no-gate'
|
|
39
|
+
? 'network move refused: this host has no backup gate, so a fresh backup cannot be checked'
|
|
40
|
+
: reason === 'no-backup'
|
|
41
|
+
? `network move refused: not every database in the company's project has a finished dump from the last ${MOVE_BACKUP_MAX_AGE_MINUTES} minutes, checked in Coolify; take one now, or move this company by hand with docs/runbooks/network-move.md`
|
|
42
|
+
: "network move refused: the company's project holds something no backup covers (its mail server or an app with a volume), or Coolify could not be asked; move this company by hand with docs/runbooks/network-move.md");
|
|
43
|
+
this.reason = reason;
|
|
44
|
+
this.name = 'NetworkMoveBackupMissingError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function withinTimeout(call) {
|
|
48
|
+
let timer;
|
|
49
|
+
try {
|
|
50
|
+
return await Promise.race([
|
|
51
|
+
call(),
|
|
52
|
+
new Promise((_, reject) => {
|
|
53
|
+
timer = setTimeout(() => reject(new Error('timeout')), GATE_CALL_TIMEOUT_MS);
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Coolify 4.x answers `GET /databases/{uuid}/backups/{backup}/executions` with
|
|
63
|
+
* `{ executions: [{ uuid, filename, size, created_at, message, status }] }`,
|
|
64
|
+
* newest first; `status` is `success`, `failed` or `running`. Read from
|
|
65
|
+
* `routes/api.php` and `DatabasesController::list_backup_executions` on the
|
|
66
|
+
* v4.x branch, 2026-10-10; not run against a live instance. Anything else is
|
|
67
|
+
* a shape this gate cannot trust, so it throws.
|
|
68
|
+
*/
|
|
69
|
+
function parseExecutions(body) {
|
|
70
|
+
const list = Array.isArray(body) ? body : body?.executions;
|
|
71
|
+
if (!Array.isArray(list))
|
|
72
|
+
throw new Error('executions list has an unexpected shape');
|
|
73
|
+
return list.map((row) => {
|
|
74
|
+
const r = row;
|
|
75
|
+
if (!r ||
|
|
76
|
+
typeof r.uuid !== 'string' ||
|
|
77
|
+
typeof r.status !== 'string' ||
|
|
78
|
+
typeof r.created_at !== 'string') {
|
|
79
|
+
throw new Error('executions list has an unexpected shape');
|
|
80
|
+
}
|
|
81
|
+
return { uuid: r.uuid, status: r.status, created_at: r.created_at };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** The executions of one schedule, or `null` when this Coolify has no such route. */
|
|
85
|
+
async function readExecutions(coolify, databaseUuid, backupUuid) {
|
|
86
|
+
try {
|
|
87
|
+
const list = coolify.listDatabaseBackupExecutions?.bind(coolify);
|
|
88
|
+
if (list)
|
|
89
|
+
return parseExecutions(await withinTimeout(() => list(databaseUuid, backupUuid)));
|
|
90
|
+
return parseExecutions(await withinTimeout(() => coolify.raw('GET', `/databases/${databaseUuid}/backups/${backupUuid}/executions`)));
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
if (isRouteMissing(e))
|
|
94
|
+
return null;
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function isRouteMissing(error) {
|
|
99
|
+
const status = error?.status;
|
|
100
|
+
if (typeof status === 'number')
|
|
101
|
+
return status === 404;
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
+
return /^\S+ \S+ \S+ → 404:/.test(message);
|
|
104
|
+
}
|
|
105
|
+
/** The newest `success` execution that is not older than the window, or null. */
|
|
106
|
+
function freshSuccess(executions, now, maxAgeMs = BACKUP_MAX_AGE_HOURS * 3_600_000, skewMs = 3_600_000) {
|
|
107
|
+
const oldest = now - maxAgeMs;
|
|
108
|
+
let best = null;
|
|
109
|
+
for (const execution of executions) {
|
|
110
|
+
if (execution.status !== 'success')
|
|
111
|
+
continue;
|
|
112
|
+
const at = Date.parse(execution.created_at);
|
|
113
|
+
if (Number.isNaN(at) || at < oldest || at > now + skewMs)
|
|
114
|
+
continue;
|
|
115
|
+
if (!best || at > best.at)
|
|
116
|
+
best = { execution, at };
|
|
117
|
+
}
|
|
118
|
+
return best?.execution ?? null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Refuses a confirmed upgrade of a stateful service whose database has no
|
|
122
|
+
* finished backup in the last {@link BACKUP_MAX_AGE_HOURS} hours. Runs after
|
|
123
|
+
* `unconfirmedUpgrades` and before any step. A service whose database the
|
|
124
|
+
* company does not have passes (there is nothing to migrate); a database that
|
|
125
|
+
* exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
|
|
126
|
+
* old dump, or an unreadable list all refuse. `mail` has no Postgres and is
|
|
127
|
+
* not covered here (plan note `backup-not-covered:mail`).
|
|
128
|
+
*
|
|
129
|
+
* Where this Coolify has no executions route, the fallback is that the
|
|
130
|
+
* schedule exists, is enabled and saves to S3; that proves a dump is
|
|
131
|
+
* scheduled, not that one ran, so it passes with no backup id.
|
|
132
|
+
*
|
|
133
|
+
* Returns the uuid of the newest fresh execution across the checked
|
|
134
|
+
* databases, for the caller to record as `backupId`, or `null`.
|
|
135
|
+
*/
|
|
136
|
+
export async function requireFreshBackupsForUpgrades(plan, confirmed, ports) {
|
|
137
|
+
const now = (ports.now ?? Date.now)();
|
|
138
|
+
const keys = ports.scope?.keys ?? companyDatabaseKeys({ id: ports.orgId });
|
|
139
|
+
const names = Object.keys(keys);
|
|
140
|
+
const confirmedSet = new Set(confirmed ?? []);
|
|
141
|
+
let backupId = null;
|
|
142
|
+
let newest = 0;
|
|
143
|
+
for (const change of plan.versionChanges ?? []) {
|
|
144
|
+
if (!change.needsConfirmation || !confirmedSet.has(change.service))
|
|
145
|
+
continue;
|
|
146
|
+
const wanted = (ports.scope?.forService ?? databasesForService)(change.service);
|
|
147
|
+
const dbs = wanted === 'all' ? names : wanted;
|
|
148
|
+
for (const db of dbs) {
|
|
149
|
+
const databaseUuid = ports.recordedIds[keys[db]];
|
|
150
|
+
if (!databaseUuid)
|
|
151
|
+
continue;
|
|
152
|
+
let backupUuid = ports.recordedIds[backupScopeKey(db)];
|
|
153
|
+
if (!backupUuid && ports.scope) {
|
|
154
|
+
const schedules = await withinTimeout(() => ports.coolify.listDatabaseBackups(databaseUuid)).catch(() => []);
|
|
155
|
+
backupUuid = schedules.find((x) => x.enabled && x.save_s3)?.uuid;
|
|
156
|
+
}
|
|
157
|
+
if (!backupUuid)
|
|
158
|
+
throw new UpgradeBackupMissingError(change.service);
|
|
159
|
+
let executions;
|
|
160
|
+
try {
|
|
161
|
+
executions = await readExecutions(ports.coolify, databaseUuid, backupUuid);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// An unreadable list is not a fresh backup. The cause stays out of the
|
|
165
|
+
// message: it can carry a response body.
|
|
166
|
+
throw new UpgradeBackupMissingError(change.service);
|
|
167
|
+
}
|
|
168
|
+
if (executions === null) {
|
|
169
|
+
const schedules = await withinTimeout(() => ports.coolify.listDatabaseBackups(databaseUuid)).catch(() => []);
|
|
170
|
+
const ok = schedules.some((s) => s.uuid === backupUuid && s.enabled && s.save_s3);
|
|
171
|
+
if (!ok)
|
|
172
|
+
throw new UpgradeBackupMissingError(change.service, true);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const fresh = freshSuccess(executions, now);
|
|
176
|
+
if (!fresh)
|
|
177
|
+
throw new UpgradeBackupMissingError(change.service);
|
|
178
|
+
const at = Date.parse(fresh.created_at);
|
|
179
|
+
if (at >= newest) {
|
|
180
|
+
newest = at;
|
|
181
|
+
backupId = fresh.uuid;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { backupId };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The gate for a move to the company's own network, which deletes every database of the project. It
|
|
189
|
+
* fails closed and is stricter than the upgrade gate: it reads the project itself, not foundry's records.
|
|
190
|
+
* It passes only when every database in the project has a successful dump, seen in Coolify's
|
|
191
|
+
* executions, from the last {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes, and the project holds nothing
|
|
192
|
+
* a dump does not cover. It refuses (`by-hand`) the company's own mail server and any app with a
|
|
193
|
+
* volume other than its log volume, and (`no-backup`) a database with no schedule or no execution, an
|
|
194
|
+
* unreadable list, and a Coolify with no executions route. A company with neither database nor volume passes.
|
|
195
|
+
*/
|
|
196
|
+
export async function requireFreshBackupsForMove(ports) {
|
|
197
|
+
const { coolify } = ports;
|
|
198
|
+
const listResources = coolify.listResources?.bind(coolify);
|
|
199
|
+
const listStorages = coolify.listStorages?.bind(coolify);
|
|
200
|
+
if (!ports.projectUuid || !listResources || !listStorages || ports.ownsMail) {
|
|
201
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
202
|
+
}
|
|
203
|
+
const projectUuid = ports.projectUuid;
|
|
204
|
+
const now = (ports.now ?? Date.now)();
|
|
205
|
+
let resources;
|
|
206
|
+
try {
|
|
207
|
+
resources = await withinTimeout(() => listResources(projectUuid));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
211
|
+
}
|
|
212
|
+
for (const app of resources.filter((r) => r.kind === 'application')) {
|
|
213
|
+
if (typeof app.key !== 'string')
|
|
214
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
215
|
+
const appUuid = app.key;
|
|
216
|
+
let storages;
|
|
217
|
+
try {
|
|
218
|
+
storages = await withinTimeout(() => listStorages(appUuid));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
222
|
+
}
|
|
223
|
+
if ((storages.persistent_storages ?? []).some((v) => v.mount_path !== LOG_DIR)) {
|
|
224
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const database of resources.filter((r) => r.kind === 'database')) {
|
|
228
|
+
if (typeof database.key !== 'string')
|
|
229
|
+
throw new NetworkMoveBackupMissingError('no-backup');
|
|
230
|
+
const databaseUuid = database.key;
|
|
231
|
+
let fresh = false;
|
|
232
|
+
try {
|
|
233
|
+
const schedules = await withinTimeout(() => coolify.listDatabaseBackups(databaseUuid));
|
|
234
|
+
for (const schedule of schedules) {
|
|
235
|
+
// A missing executions route is a refusal: a schedule alone proves no dump ran.
|
|
236
|
+
const executions = await readExecutions(coolify, databaseUuid, schedule.uuid);
|
|
237
|
+
if (executions &&
|
|
238
|
+
freshSuccess(executions, now, MOVE_BACKUP_MAX_AGE_MINUTES * 60_000, 5 * 60_000)) {
|
|
239
|
+
fresh = true;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// The cause can carry a response body.
|
|
246
|
+
throw new NetworkMoveBackupMissingError('no-backup');
|
|
247
|
+
}
|
|
248
|
+
if (!fresh)
|
|
249
|
+
throw new NetworkMoveBackupMissingError('no-backup');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
|
|
254
|
+
* integrations each migrate their own database when the new container starts. The owner company runs its own
|
|
255
|
+
* sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
|
|
256
|
+
*/
|
|
257
|
+
export function platformDatabasesForService(service) {
|
|
258
|
+
if (service === 'postgres')
|
|
259
|
+
return 'all';
|
|
260
|
+
return service === 'archon' || service === 'ai' || service === 'integrations' ? [service] : [];
|
|
261
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
2
|
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
3
3
|
import type { CoolifyPort } from './executor.js';
|
|
4
4
|
/**
|
|
@@ -64,6 +64,13 @@ export interface BrokerPort {
|
|
|
64
64
|
/** The platform's `PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY` value, if any. */
|
|
65
65
|
recordedDeploy(): Promise<string | undefined>;
|
|
66
66
|
recordDeploy(value: string): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* The id of Archon's own sign-in organisation, when the platform has made it.
|
|
69
|
+
* No other entry may sign in that organisation, so an entry that does is
|
|
70
|
+
* refused before anything is written. Optional: a port that cannot read it
|
|
71
|
+
* (an older host) skips the check.
|
|
72
|
+
*/
|
|
73
|
+
archonOrganisationId?(): Promise<string | undefined>;
|
|
67
74
|
}
|
|
68
75
|
/** {@link BrokerPort} over the platform's recorded ids and a Coolify client. */
|
|
69
76
|
export declare class PlatformBrokerPort implements BrokerPort {
|
|
@@ -89,6 +96,7 @@ export declare class PlatformBrokerPort implements BrokerPort {
|
|
|
89
96
|
setAuthApps(raw: string): Promise<void>;
|
|
90
97
|
deploy(): Promise<void>;
|
|
91
98
|
recordedDeploy(): Promise<string | undefined>;
|
|
99
|
+
archonOrganisationId(): Promise<string | undefined>;
|
|
92
100
|
recordDeploy(value: string): Promise<void>;
|
|
93
101
|
}
|
|
94
102
|
/**
|