@wtfalch/foundry 0.42.1 → 0.44.0
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/versions.d.ts +2 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/migrations/0055_release_requests.sql +20 -0
- package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
- package/dist/migrations/0057_org_customers.sql +29 -0
- package/dist/org.d.ts +55 -0
- package/dist/org.js +90 -27
- package/dist/plan/archon-blue-green.d.ts +54 -0
- package/dist/plan/archon-blue-green.js +72 -0
- package/dist/plan/blue-green-apply.d.ts +82 -0
- package/dist/plan/blue-green-apply.js +360 -0
- package/dist/plan/blue-green.d.ts +223 -0
- package/dist/plan/blue-green.js +444 -0
- package/dist/plan/boule.d.ts +43 -2
- package/dist/plan/boule.js +48 -1
- package/dist/plan/broker-entries.d.ts +28 -0
- package/dist/plan/broker-entries.js +46 -1
- package/dist/plan/cloudflared-connector.d.ts +26 -0
- package/dist/plan/cloudflared-connector.js +37 -0
- package/dist/plan/diff.d.ts +20 -0
- package/dist/plan/diff.js +207 -20
- package/dist/plan/executor.d.ts +134 -2
- package/dist/plan/executor.js +517 -25
- package/dist/plan/hostnames.d.ts +7 -0
- package/dist/plan/hostnames.js +17 -2
- package/dist/plan/locked-hostnames.d.ts +11 -0
- package/dist/plan/locked-hostnames.js +13 -0
- package/dist/plan/mcp.d.ts +57 -0
- package/dist/plan/mcp.js +131 -0
- package/dist/plan/org-names.d.ts +2 -2
- package/dist/plan/org-names.js +16 -1
- package/dist/plan/plan-apply.d.ts +77 -0
- package/dist/plan/plan-apply.js +267 -5
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +24 -1
- package/dist/plan/platform-providers.d.ts +15 -1
- package/dist/plan/platform-providers.js +90 -5
- package/dist/plan/proxy-labels.d.ts +2 -2
- package/dist/plan/proxy-labels.js +14 -4
- package/dist/plan/release-host.d.ts +59 -0
- package/dist/plan/release-host.js +239 -0
- package/dist/plan/remote-database.d.ts +124 -0
- package/dist/plan/remote-database.js +373 -0
- package/dist/plan/worker-app-database.d.ts +148 -0
- package/dist/plan/worker-app-database.js +424 -0
- package/dist/plan/worker-app.d.ts +320 -0
- package/dist/plan/worker-app.js +792 -0
- package/dist/plan/zitadel-machine-user.d.ts +13 -1
- package/dist/plan/zitadel-machine-user.js +80 -0
- package/dist/platform.d.ts +11 -1
- package/dist/platform.js +4 -1
- package/dist/release/decide.d.ts +95 -0
- package/dist/release/decide.js +242 -0
- package/dist/release/health.d.ts +48 -0
- package/dist/release/health.js +61 -0
- package/dist/release/run.d.ts +40 -0
- package/dist/release/run.js +56 -0
- package/dist/release/state.d.ts +64 -0
- package/dist/release/state.js +126 -0
- package/dist/release/types.d.ts +54 -0
- package/dist/release/types.js +4 -0
- package/dist/requested-changes.d.ts +12 -3
- package/dist/requested-changes.js +22 -0
- package/dist/store/postgres.d.ts +8 -0
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +188 -0
- package/dist/store/schema.js +15 -0
- package/dist/worker-app-config.d.ts +151 -0
- package/dist/worker-app-config.js +438 -0
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- package/versions.json +2 -1
package/dist/plan/plan-apply.js
CHANGED
|
@@ -8,6 +8,7 @@ import { StaleLeaseError, } from '../store.js';
|
|
|
8
8
|
import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
|
|
9
9
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
10
10
|
import { NetworkMoveBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
|
|
11
|
+
import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, entrySubject, } from './blue-green.js';
|
|
11
12
|
import { readLiveBuilds } from './build-record.js';
|
|
12
13
|
import { unconfirmedUpgrades } from './confirm-upgrade.js';
|
|
13
14
|
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
@@ -15,7 +16,7 @@ import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
|
|
|
15
16
|
import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
16
17
|
import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
|
|
17
18
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
18
|
-
import {
|
|
19
|
+
import { hostingNames } from './locked-hostnames.js';
|
|
19
20
|
import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
|
|
20
21
|
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_MAX, OWN_MAIL_SMTP_PORT_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, isOwnMailSmtpPort, mailRelayFingerprint, } from './mail-relay.js';
|
|
21
22
|
import { findNameClashes, nameClashMessage } from './org-names.js';
|
|
@@ -25,6 +26,9 @@ import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILE
|
|
|
25
26
|
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerChatFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
|
|
26
27
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, } from './platform-providers.js';
|
|
27
28
|
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
|
|
29
|
+
import { runSubjectRelease } from './release-host.js';
|
|
30
|
+
import { remoteDatabaseFactsFrom } from './remote-database.js';
|
|
31
|
+
import { liveWorkerApps, workerAppTraceScopeKeys } from './worker-app.js';
|
|
28
32
|
import { liveWorkers, workerTraceScopeKeys } from './worker.js';
|
|
29
33
|
/** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
|
|
30
34
|
const companyScope = (orgId) => `company:${orgId}`;
|
|
@@ -119,6 +123,9 @@ export class PlatformConfirmationRequiredError extends Error {
|
|
|
119
123
|
/** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
|
|
120
124
|
const HOSTNAME_STEPS = new Set([
|
|
121
125
|
'coolify:app',
|
|
126
|
+
'coolify:bluegreen',
|
|
127
|
+
'coolify:bluegreen-lb',
|
|
128
|
+
'coolify:bluegreen-connector',
|
|
122
129
|
'coolify:broker-entry',
|
|
123
130
|
'coolify:database',
|
|
124
131
|
'cloudflare:tunnel',
|
|
@@ -128,6 +135,8 @@ const HOSTNAME_STEPS = new Set([
|
|
|
128
135
|
// #318 (F8): the route is itself hostname-shaped, validated against the
|
|
129
136
|
// org's own domain by `validateEntryWorker`.
|
|
130
137
|
'cloudflare:worker',
|
|
138
|
+
// ADR 0002: its host is hostname-shaped and validated by `validateEntryWorkerApp`.
|
|
139
|
+
'cloudflare:worker-app',
|
|
131
140
|
]);
|
|
132
141
|
/**
|
|
133
142
|
* The #19 implementation of {@link PlanApply}: computes a plan against live
|
|
@@ -152,8 +161,13 @@ export class FoundryPlanApply {
|
|
|
152
161
|
loginHostBindings;
|
|
153
162
|
loginHostDiscovery;
|
|
154
163
|
zitadelIdTokenUserinfo;
|
|
164
|
+
zitadelMcp;
|
|
155
165
|
broker;
|
|
156
166
|
edgeWorkers;
|
|
167
|
+
workerApps;
|
|
168
|
+
workerAppKeysUrl;
|
|
169
|
+
workerAppDatabase;
|
|
170
|
+
workerAppRegistryAccountId;
|
|
157
171
|
ownerInviteMail;
|
|
158
172
|
hostFiles;
|
|
159
173
|
hostAi;
|
|
@@ -167,6 +181,7 @@ export class FoundryPlanApply {
|
|
|
167
181
|
backupGate;
|
|
168
182
|
platformDeps;
|
|
169
183
|
runner;
|
|
184
|
+
releaseStore;
|
|
170
185
|
constructor(options) {
|
|
171
186
|
this.store = options.store;
|
|
172
187
|
this.readers = options.readers;
|
|
@@ -180,8 +195,13 @@ export class FoundryPlanApply {
|
|
|
180
195
|
this.loginHostBindings = options.loginHostBindings ?? false;
|
|
181
196
|
this.loginHostDiscovery = options.loginHostDiscovery;
|
|
182
197
|
this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
|
|
198
|
+
this.zitadelMcp = options.zitadelMcp ?? false;
|
|
183
199
|
this.broker = options.broker ?? false;
|
|
184
200
|
this.edgeWorkers = options.edgeWorkers ?? false;
|
|
201
|
+
this.workerApps = options.workerApps ?? false;
|
|
202
|
+
this.workerAppKeysUrl = options.workerAppKeysUrl;
|
|
203
|
+
this.workerAppDatabase = options.workerAppDatabase ?? false;
|
|
204
|
+
this.workerAppRegistryAccountId = options.workerAppRegistryAccountId;
|
|
185
205
|
this.ownerInviteMail = options.ownerInviteMail ?? false;
|
|
186
206
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
187
207
|
this.hostAi = options.aiOrganisation ?? false;
|
|
@@ -195,6 +215,7 @@ export class FoundryPlanApply {
|
|
|
195
215
|
this.backupGate = options.backupGate;
|
|
196
216
|
this.platformDeps = options.platform;
|
|
197
217
|
this.runner = options.runner;
|
|
218
|
+
this.releaseStore = options.releaseStore;
|
|
198
219
|
}
|
|
199
220
|
async plan(id) {
|
|
200
221
|
const { plan } = await this.computeFreshPlan(id);
|
|
@@ -689,6 +710,12 @@ export class FoundryPlanApply {
|
|
|
689
710
|
delete serviceIds[scopeKey];
|
|
690
711
|
}
|
|
691
712
|
}
|
|
713
|
+
if (target.system === 'cloudflare' && target.kind === 'worker-app') {
|
|
714
|
+
const entryKey = target.key.slice(id.length + 1);
|
|
715
|
+
for (const scopeKey of workerAppTraceScopeKeys(entryKey)) {
|
|
716
|
+
delete serviceIds[scopeKey];
|
|
717
|
+
}
|
|
718
|
+
}
|
|
692
719
|
steps[current] = {
|
|
693
720
|
step: descriptions[current],
|
|
694
721
|
status: 'finished',
|
|
@@ -953,6 +980,13 @@ export class FoundryPlanApply {
|
|
|
953
980
|
}
|
|
954
981
|
checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
|
|
955
982
|
}
|
|
983
|
+
// The steps made the structure; the release runner then moves traffic on it, on the same live ids.
|
|
984
|
+
await this.runOrgReleases(org, {
|
|
985
|
+
recordedIds: context.recordedIds,
|
|
986
|
+
recordScopeId: context.recordScopeId,
|
|
987
|
+
forgetId: (key) => context.forgetId?.(key),
|
|
988
|
+
persist: context.persistScopeIds,
|
|
989
|
+
}, fence);
|
|
956
990
|
}
|
|
957
991
|
catch (e) {
|
|
958
992
|
// #9 (resolved): "it stops at the checkpoint... there is no automatic
|
|
@@ -1012,6 +1046,135 @@ export class FoundryPlanApply {
|
|
|
1012
1046
|
});
|
|
1013
1047
|
return applied;
|
|
1014
1048
|
}
|
|
1049
|
+
/**
|
|
1050
|
+
* ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
|
|
1051
|
+
* (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
|
|
1052
|
+
* before this returns.
|
|
1053
|
+
*/
|
|
1054
|
+
async runOrgReleases(org, context, fence) {
|
|
1055
|
+
const store = this.releaseStore;
|
|
1056
|
+
const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
|
|
1057
|
+
if (!store || !this.executor.releaseHost || !primaryUuid)
|
|
1058
|
+
return;
|
|
1059
|
+
for (const entry of org.hosting.entries) {
|
|
1060
|
+
const subject = entrySubject(org, entry, context.recordedIds());
|
|
1061
|
+
if (!subject)
|
|
1062
|
+
continue;
|
|
1063
|
+
const host = this.executor.releaseHost(org, subject, context, () => this.renewLease(fence));
|
|
1064
|
+
if (!host)
|
|
1065
|
+
continue;
|
|
1066
|
+
await runSubjectRelease({
|
|
1067
|
+
store,
|
|
1068
|
+
scopeOwnerId: org.id,
|
|
1069
|
+
subject,
|
|
1070
|
+
host,
|
|
1071
|
+
context,
|
|
1072
|
+
now: this.now(),
|
|
1073
|
+
primaryUuid,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
/** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
|
|
1078
|
+
async runPlatformReleases(platform, context, fence) {
|
|
1079
|
+
const store = this.releaseStore;
|
|
1080
|
+
const executor = this.platformDeps?.executor;
|
|
1081
|
+
const subject = archonSubject(platform, ARCHON_REPO);
|
|
1082
|
+
const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
|
|
1083
|
+
if (!store || !executor?.releaseHost || !subject || !primaryUuid)
|
|
1084
|
+
return;
|
|
1085
|
+
const host = executor.releaseHost(platform, subject, context, () => this.renewLease(fence));
|
|
1086
|
+
if (!host)
|
|
1087
|
+
return;
|
|
1088
|
+
await runSubjectRelease({
|
|
1089
|
+
store,
|
|
1090
|
+
scopeOwnerId: 'platform',
|
|
1091
|
+
subject,
|
|
1092
|
+
host,
|
|
1093
|
+
context,
|
|
1094
|
+
now: this.now(),
|
|
1095
|
+
primaryUuid,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
async runPlatformReleaseTick() {
|
|
1099
|
+
const deps = this.platformDeps;
|
|
1100
|
+
const platform = await deps?.store.get();
|
|
1101
|
+
if (!deps || !platform)
|
|
1102
|
+
return;
|
|
1103
|
+
const empty = { orgId: platform.ownerOrgId, steps: [], unmanaged: [] };
|
|
1104
|
+
if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
|
|
1105
|
+
await this.refusePlatform(empty, platform.ownerOrgId, 'not authorized to run the release tick');
|
|
1106
|
+
throw new NotAuthorizedError(this.actor, platform.ownerOrgId);
|
|
1107
|
+
}
|
|
1108
|
+
await this.withMutationLease(PLATFORM_SCOPE, (reason) => this.refusePlatform(empty, platform.ownerOrgId, reason), async (fence) => {
|
|
1109
|
+
const serviceIds = { ...(await deps.store.getServiceIds()) };
|
|
1110
|
+
await this.runPlatformReleases(platform, {
|
|
1111
|
+
recordedIds: () => ({ ...serviceIds }),
|
|
1112
|
+
recordScopeId: (key, value) => {
|
|
1113
|
+
serviceIds[key] = value;
|
|
1114
|
+
},
|
|
1115
|
+
forgetId: (key) => {
|
|
1116
|
+
delete serviceIds[key];
|
|
1117
|
+
},
|
|
1118
|
+
persist: () => deps.store.putServiceIds({ ...serviceIds }, fence),
|
|
1119
|
+
}, fence);
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
|
|
1124
|
+
* archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
|
|
1125
|
+
* no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
|
|
1126
|
+
* apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
|
|
1127
|
+
* A refusal is audited; a run that does nothing writes no audit row.
|
|
1128
|
+
*/
|
|
1129
|
+
async runReleaseTick() {
|
|
1130
|
+
const results = [];
|
|
1131
|
+
for (const org of await this.store.list()) {
|
|
1132
|
+
if (!org.hosting.entries.some((entry) => entry.blueGreen))
|
|
1133
|
+
continue;
|
|
1134
|
+
try {
|
|
1135
|
+
await this.runOrgReleaseTick(org.id);
|
|
1136
|
+
results.push({ target: org.id });
|
|
1137
|
+
}
|
|
1138
|
+
catch (e) {
|
|
1139
|
+
results.push({ target: org.id, error: e instanceof Error ? e.message : String(e) });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const platform = await this.platformDeps?.store.get();
|
|
1143
|
+
if (platform?.archon.enabled && platform.archon.blueGreen) {
|
|
1144
|
+
try {
|
|
1145
|
+
await this.runPlatformReleaseTick();
|
|
1146
|
+
results.push({ target: 'platform' });
|
|
1147
|
+
}
|
|
1148
|
+
catch (e) {
|
|
1149
|
+
results.push({ target: 'platform', error: e instanceof Error ? e.message : String(e) });
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return results;
|
|
1153
|
+
}
|
|
1154
|
+
async runOrgReleaseTick(id) {
|
|
1155
|
+
const empty = { orgId: id, steps: [], unmanaged: [] };
|
|
1156
|
+
if (!(await this.authz.canApply(this.actor, id))) {
|
|
1157
|
+
await this.refuse(empty, 'not authorized to run the release tick');
|
|
1158
|
+
throw new NotAuthorizedError(this.actor, id);
|
|
1159
|
+
}
|
|
1160
|
+
await this.withMutationLease(companyScope(id), (reason) => this.refuse(empty, reason), async (fence) => {
|
|
1161
|
+
const org = await this.store.get(id);
|
|
1162
|
+
const previous = await this.store.getAppliedState(id);
|
|
1163
|
+
if (!org || !previous)
|
|
1164
|
+
return;
|
|
1165
|
+
const serviceIds = { ...previous.serviceIds };
|
|
1166
|
+
await this.runOrgReleases(org, {
|
|
1167
|
+
recordedIds: () => ({ ...serviceIds }),
|
|
1168
|
+
recordScopeId: (key, value) => {
|
|
1169
|
+
serviceIds[key] = value;
|
|
1170
|
+
},
|
|
1171
|
+
forgetId: (key) => {
|
|
1172
|
+
delete serviceIds[key];
|
|
1173
|
+
},
|
|
1174
|
+
persist: () => this.store.putAppliedState({ ...previous, serviceIds: { ...serviceIds } }, fence),
|
|
1175
|
+
}, fence);
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1015
1178
|
/** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
|
|
1016
1179
|
async readSfuPortsOpen(org) {
|
|
1017
1180
|
const slot = org.services?.sfuSlot;
|
|
@@ -1048,14 +1211,23 @@ export class FoundryPlanApply {
|
|
|
1048
1211
|
: false;
|
|
1049
1212
|
const sfuPortsOpen = await this.readSfuPortsOpen(org);
|
|
1050
1213
|
const resumable = await this.ownResumableRefs(org);
|
|
1214
|
+
// Read only for an org that declares it; a failed read plans none and says so (`planBlueGreen`).
|
|
1215
|
+
const blueGreenServers = org.hosting.entries.some((e) => e.blueGreen) && this.executor.blueGreenServers
|
|
1216
|
+
? await this.executor.blueGreenServers().catch(() => undefined)
|
|
1217
|
+
: undefined;
|
|
1051
1218
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
1052
1219
|
loginHostBindings: this.loginHostBindings,
|
|
1053
1220
|
loginHostServesDiscovery,
|
|
1054
1221
|
zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
|
|
1222
|
+
zitadelMcp: this.zitadelMcp,
|
|
1055
1223
|
broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
|
|
1056
1224
|
? await this.executor.ownBrokerPort(org, recorded)
|
|
1057
1225
|
: this.broker,
|
|
1058
1226
|
edgeWorkers: this.edgeWorkers,
|
|
1227
|
+
workerApps: this.workerApps,
|
|
1228
|
+
workerAppKeysUrl: this.workerAppKeysUrl,
|
|
1229
|
+
workerAppDatabase: this.workerAppDatabase,
|
|
1230
|
+
workerAppRegistryAccountId: this.workerAppRegistryAccountId,
|
|
1059
1231
|
ownerInviteMail: this.ownerInviteMail,
|
|
1060
1232
|
otherSlugs,
|
|
1061
1233
|
now: () => this.now().getTime(),
|
|
@@ -1066,6 +1238,20 @@ export class FoundryPlanApply {
|
|
|
1066
1238
|
oikosZoneReady: platformReadiness.oikosIngressApplied === true,
|
|
1067
1239
|
ownMailKeyHeld: await this.ownMailKeyHeld(org),
|
|
1068
1240
|
ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
|
|
1241
|
+
customersDirectory: this.executor.customersDirectoryAvailable?.() ?? false,
|
|
1242
|
+
bouleArchonCredential: this.executor.bouleArchonCredentialAvailable?.() ?? false,
|
|
1243
|
+
...(blueGreenServers ? { blueGreenServers } : {}),
|
|
1244
|
+
...(blueGreenServers && org.hosting.entries.some((e) => e.blueGreen && e.database)
|
|
1245
|
+
? {
|
|
1246
|
+
remoteDatabase: remoteDatabaseFactsFrom({
|
|
1247
|
+
org,
|
|
1248
|
+
servers: blueGreenServers,
|
|
1249
|
+
recorded,
|
|
1250
|
+
access: !platformReadiness.dnsBlocked ||
|
|
1251
|
+
['dns', 'zones', 'access', 'tunnels'].every((port) => platformReadiness.ownEdgePorts?.[port]),
|
|
1252
|
+
}),
|
|
1253
|
+
}
|
|
1254
|
+
: {}),
|
|
1069
1255
|
ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
|
|
1070
1256
|
ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
|
|
1071
1257
|
...(this.backupGate
|
|
@@ -1149,10 +1335,7 @@ export class FoundryPlanApply {
|
|
|
1149
1335
|
* which only the plan can read.
|
|
1150
1336
|
*/
|
|
1151
1337
|
async invalidHosting(org) {
|
|
1152
|
-
return validateHosting(org.hosting,
|
|
1153
|
-
domain: org.domain,
|
|
1154
|
-
locked: lockedHostnames(org, await this.platformServiceHostnames()),
|
|
1155
|
-
});
|
|
1338
|
+
return validateHosting(org.hosting, hostingNames(org, await this.platformServiceHostnames()));
|
|
1156
1339
|
}
|
|
1157
1340
|
async platformServiceHostnames() {
|
|
1158
1341
|
const platform = await this.platformDeps?.store.get();
|
|
@@ -1391,8 +1574,21 @@ export class FoundryPlanApply {
|
|
|
1391
1574
|
.map((org) => org.slug);
|
|
1392
1575
|
const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs() : null;
|
|
1393
1576
|
const owner = await this.platformOwnerInput(platform);
|
|
1577
|
+
const archonBlueGreen = platform.archon.enabled && platform.archon.blueGreen && executor.archonBlueGreen
|
|
1578
|
+
? await executor.archonBlueGreen().catch(() => ({ servers: undefined }))
|
|
1579
|
+
: null;
|
|
1394
1580
|
const detail = computePlatformPlanDetail(platform, live, {
|
|
1395
1581
|
owner,
|
|
1582
|
+
...(archonBlueGreen
|
|
1583
|
+
? {
|
|
1584
|
+
archonBlueGreen: {
|
|
1585
|
+
servers: archonBlueGreen.servers,
|
|
1586
|
+
...('blocked' in archonBlueGreen && archonBlueGreen.blocked
|
|
1587
|
+
? { blocked: archonBlueGreen.blocked }
|
|
1588
|
+
: {}),
|
|
1589
|
+
},
|
|
1590
|
+
}
|
|
1591
|
+
: {}),
|
|
1396
1592
|
jobsObservable: readers.coolify !== undefined,
|
|
1397
1593
|
sfuBlocked,
|
|
1398
1594
|
callsOrgSlugs,
|
|
@@ -1515,6 +1711,14 @@ export class FoundryPlanApply {
|
|
|
1515
1711
|
serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
|
|
1516
1712
|
}
|
|
1517
1713
|
}
|
|
1714
|
+
await this.runPlatformReleases(platform, {
|
|
1715
|
+
recordedIds: context.recordedIds,
|
|
1716
|
+
recordScopeId: context.recordScopeId,
|
|
1717
|
+
forgetId: (key) => {
|
|
1718
|
+
delete serviceIds[key];
|
|
1719
|
+
},
|
|
1720
|
+
persist: () => store.putServiceIds(serviceIds, fence),
|
|
1721
|
+
}, fence);
|
|
1518
1722
|
}
|
|
1519
1723
|
catch (e) {
|
|
1520
1724
|
// Whatever completed stays recorded, as an org's apply does. A lost
|
|
@@ -1600,6 +1804,8 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1600
1804
|
'cloudflare:gate:',
|
|
1601
1805
|
// #318: an entry's Worker and its route.
|
|
1602
1806
|
'cloudflare:worker:',
|
|
1807
|
+
// ADR 0002: a Worker app's route, record, containers, keys and script.
|
|
1808
|
+
'cloudflare:worker-app:',
|
|
1603
1809
|
'coolify:database:',
|
|
1604
1810
|
'coolify:app:',
|
|
1605
1811
|
// An own files host's storage key: the token id is the step's recorded resource id.
|
|
@@ -1630,6 +1836,21 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1630
1836
|
}
|
|
1631
1837
|
continue;
|
|
1632
1838
|
}
|
|
1839
|
+
if (prefix === 'cloudflare:worker-app:') {
|
|
1840
|
+
for (const [entryKey, scriptName] of liveWorkerApps(orgId, serviceIds)) {
|
|
1841
|
+
targets.push({
|
|
1842
|
+
system: 'cloudflare',
|
|
1843
|
+
kind: 'worker-app',
|
|
1844
|
+
key: `${orgId}:${entryKey}`,
|
|
1845
|
+
id: scriptName,
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
continue;
|
|
1849
|
+
}
|
|
1850
|
+
// Blue/green: the colors, balancers, stage, database sidecars and extra connectors, by recorded id, before
|
|
1851
|
+
// the main database they connect to. Their keys carry no ':' (scope ids), so no prefix above finds them.
|
|
1852
|
+
if (prefix === 'coolify:database:')
|
|
1853
|
+
targets.push(...blueGreenDestroyTargets(serviceIds));
|
|
1633
1854
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1634
1855
|
if (!resourceKey.startsWith(prefix) || !id)
|
|
1635
1856
|
continue;
|
|
@@ -1745,10 +1966,44 @@ function destroyStepDescriptions(targets) {
|
|
|
1745
1966
|
'delete the declared org',
|
|
1746
1967
|
];
|
|
1747
1968
|
}
|
|
1969
|
+
/** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
|
|
1970
|
+
const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
|
|
1971
|
+
const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
|
|
1972
|
+
/** The database route's Cloudflare parts: its Access app, its service token (also one minted and never delivered) and its DNS record (recorded as the route). */
|
|
1973
|
+
const BLUE_GREEN_EDGE_KEYS = [
|
|
1974
|
+
[/^bgdb\.[^.:]+\.access$/, 'bg-access'],
|
|
1975
|
+
[/^bgdb\.[^.:]+\.(token|undelivered)$/, 'bg-token'],
|
|
1976
|
+
[/^bgdb\.[^.:]+\.route$/, 'bg-dns'],
|
|
1977
|
+
];
|
|
1978
|
+
/** Every recorded blue/green app (then stage's database), in key order. A destroy target's `key` is the scope key itself. */
|
|
1979
|
+
function blueGreenDestroyTargets(serviceIds) {
|
|
1980
|
+
const keys = Object.keys(serviceIds).sort();
|
|
1981
|
+
const targets = [];
|
|
1982
|
+
for (const key of keys.filter((k) => BLUE_GREEN_APP_KEY.test(k))) {
|
|
1983
|
+
const id = serviceIds[key];
|
|
1984
|
+
if (id)
|
|
1985
|
+
targets.push({ system: 'coolify', kind: 'bg-app', key, id });
|
|
1986
|
+
}
|
|
1987
|
+
for (const key of keys.filter((k) => BLUE_GREEN_DATABASE_KEY.test(k))) {
|
|
1988
|
+
const id = serviceIds[key];
|
|
1989
|
+
if (id)
|
|
1990
|
+
targets.push({ system: 'coolify', kind: 'bg-database', key, id });
|
|
1991
|
+
}
|
|
1992
|
+
for (const [pattern, kind] of BLUE_GREEN_EDGE_KEYS) {
|
|
1993
|
+
for (const key of keys.filter((k) => pattern.test(k))) {
|
|
1994
|
+
const id = serviceIds[key];
|
|
1995
|
+
if (id)
|
|
1996
|
+
targets.push({ system: 'cloudflare', kind, key, id });
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
return targets;
|
|
2000
|
+
}
|
|
1748
2001
|
/** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
|
|
1749
2002
|
function destroyServiceIdKey(target) {
|
|
1750
2003
|
if (target.kind === 'project' || target.kind === 'org')
|
|
1751
2004
|
return target.key;
|
|
2005
|
+
if (target.kind.startsWith('bg-'))
|
|
2006
|
+
return target.key;
|
|
1752
2007
|
if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
|
|
1753
2008
|
return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
|
|
1754
2009
|
}
|
|
@@ -1764,6 +2019,9 @@ const STEP_FIELDS = {
|
|
|
1764
2019
|
'zitadel:owner': 'owners',
|
|
1765
2020
|
'zitadel:customers': 'hosting.entries.uses.auth',
|
|
1766
2021
|
'zitadel:id-token-userinfo': 'hosting.entries.uses.auth',
|
|
2022
|
+
'zitadel:mcp-project': 'hosting.entries.uses.auth.mcp',
|
|
2023
|
+
'zitadel:mcp-access': 'hosting.entries.uses.auth.mcp',
|
|
2024
|
+
'zitadel:access-token-lifetime': 'hosting.entries.uses.auth.mcp',
|
|
1767
2025
|
'coolify:broker-entry': 'hosting.entries.uses.auth',
|
|
1768
2026
|
'coolify:mail': 'mail',
|
|
1769
2027
|
'coolify:chat': 'chat.usesDomain',
|
|
@@ -1781,6 +2039,9 @@ const STEP_FIELDS = {
|
|
|
1781
2039
|
'ai:key': 'ai',
|
|
1782
2040
|
'cms:site': 'site',
|
|
1783
2041
|
'coolify:app': 'hosting.enabled',
|
|
2042
|
+
'coolify:bluegreen': 'hosting.entries.blueGreen',
|
|
2043
|
+
'coolify:bluegreen-lb': 'hosting.entries.blueGreen',
|
|
2044
|
+
'coolify:bluegreen-connector': 'hosting.entries.blueGreen',
|
|
1784
2045
|
'coolify:database': 'hosting.database',
|
|
1785
2046
|
'cloudflare:tunnel': 'hosting.tunnel',
|
|
1786
2047
|
'cloudflare:zone': 'hosting.usesDomain',
|
|
@@ -1788,6 +2049,7 @@ const STEP_FIELDS = {
|
|
|
1788
2049
|
'cloudflare:gate': 'hosting.entries.gates',
|
|
1789
2050
|
'cloudflare:ratelimit': 'hosting.usesDomain',
|
|
1790
2051
|
'cloudflare:worker': 'hosting.entries.worker',
|
|
2052
|
+
'cloudflare:worker-app': 'hosting.entries.workerApp',
|
|
1791
2053
|
};
|
|
1792
2054
|
function stepField(action) {
|
|
1793
2055
|
// An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
|
|
2
2
|
import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
|
|
3
3
|
import type { DeclaredPlatform } from '../platform.js';
|
|
4
|
+
import { type BlueGreenServer } from './blue-green.js';
|
|
4
5
|
import type { PlannedStep } from './diff.js';
|
|
5
6
|
import type { LiveResource } from './live.js';
|
|
6
7
|
import { type MailRoute, PLATFORM_MAIL_RELAY_KEY } from './mail-relay.js';
|
|
@@ -1092,6 +1093,14 @@ export declare function uptimeRecorded(recordedIds: Readonly<Record<string, stri
|
|
|
1092
1093
|
* the merge comment that named this shape (#189).
|
|
1093
1094
|
*/
|
|
1094
1095
|
export interface PlanPlatformOptions {
|
|
1096
|
+
/**
|
|
1097
|
+
* What `platform.archon.blueGreen` needs from the host: every Coolify server (`undefined`: it cannot list them)
|
|
1098
|
+
* and why the executor cannot run it, if it cannot. Absent: nothing is planned for it.
|
|
1099
|
+
*/
|
|
1100
|
+
archonBlueGreen?: {
|
|
1101
|
+
servers: readonly BlueGreenServer[] | undefined;
|
|
1102
|
+
blocked?: string;
|
|
1103
|
+
};
|
|
1095
1104
|
/** Whether the host supplies a Coolify reader for the platform (default `true`). */
|
|
1096
1105
|
jobsObservable?: boolean;
|
|
1097
1106
|
/** Why the shared SFU is blocked, or `null` when it is not (default `null`). */
|
|
@@ -3,6 +3,7 @@ import { VERSIONS } from '../bootstrap/versions.js';
|
|
|
3
3
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
4
4
|
import { heldEnvRef } from '../org.js';
|
|
5
5
|
import { VERSION_CHANGE_KIND, } from '../plan.js';
|
|
6
|
+
import { ARCHON_REPO, planArchonBlueGreen } from './blue-green.js';
|
|
6
7
|
import { buildDriftNotes } from './build-record.js';
|
|
7
8
|
import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
|
|
8
9
|
import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
|
|
@@ -1268,7 +1269,7 @@ export function uptimeRecorded(recordedIds) {
|
|
|
1268
1269
|
* unchanged).
|
|
1269
1270
|
*/
|
|
1270
1271
|
export function planPlatform(platform, live, options = {}) {
|
|
1271
|
-
const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailRoutesBlocked = [], recordedIds = {}, owner, liveBuilds = {}, blocked = {}, drifted = {}, } = options;
|
|
1272
|
+
const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailRoutesBlocked = [], recordedIds = {}, owner, liveBuilds = {}, blocked = {}, drifted = {}, archonBlueGreen, } = options;
|
|
1272
1273
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
1273
1274
|
const claimed = new Set();
|
|
1274
1275
|
const plannedSteps = [];
|
|
@@ -1386,6 +1387,27 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
1386
1387
|
const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld, blocked.uptimeChat);
|
|
1387
1388
|
if (uptime.step)
|
|
1388
1389
|
plannedSteps.push(uptime.step);
|
|
1390
|
+
// Archon's app and tunnel are recorded by an earlier apply, so this runs before a last-placed archon step.
|
|
1391
|
+
const archonBg = archonBlueGreen
|
|
1392
|
+
? planArchonBlueGreen({
|
|
1393
|
+
platform,
|
|
1394
|
+
repo: ARCHON_REPO,
|
|
1395
|
+
recorded: recordedIds,
|
|
1396
|
+
servers: archonBlueGreen.servers,
|
|
1397
|
+
executorBlocked: archonBlueGreen.blocked,
|
|
1398
|
+
})
|
|
1399
|
+
: { step: null, unmanaged: [] };
|
|
1400
|
+
if (archonBg.step) {
|
|
1401
|
+
plannedSteps.push({
|
|
1402
|
+
action: {
|
|
1403
|
+
system: 'coolify',
|
|
1404
|
+
kind: 'archon-bluegreen',
|
|
1405
|
+
key: archonBg.step.key,
|
|
1406
|
+
desired: archonBg.step.desired,
|
|
1407
|
+
},
|
|
1408
|
+
step: { description: archonBg.step.description, destroys: false, migrates: false },
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1389
1411
|
if (archon.step && blocked.archonRunsInsideApp)
|
|
1390
1412
|
plannedSteps.push(archon.step);
|
|
1391
1413
|
// After Archon's own step: a repair that fails must not hold up the work before it.
|
|
@@ -1487,6 +1509,7 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
1487
1509
|
unmanaged.push({ description: archonOrg.blocked, key: 'archon-org' });
|
|
1488
1510
|
if (archon.blocked)
|
|
1489
1511
|
unmanaged.push({ description: archon.blocked, key: 'archon' });
|
|
1512
|
+
unmanaged.push(...archonBg.unmanaged);
|
|
1490
1513
|
// #398: the old integrations key stays pinned until Archon holds the new
|
|
1491
1514
|
// id; name that when Archon is what is stopping it.
|
|
1492
1515
|
if (archon.blocked &&
|
|
@@ -2,6 +2,7 @@ import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
|
2
2
|
import { type DeclaredOrg } from '../org.js';
|
|
3
3
|
import type { DeclaredPlatform } from '../platform.js';
|
|
4
4
|
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
5
|
+
import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
|
|
5
6
|
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
6
7
|
import type { StepAction } from './diff.js';
|
|
7
8
|
import type { AccessPort } from './edge-access.js';
|
|
@@ -10,6 +11,7 @@ import type { RateLimitPort } from './edge-ratelimits.js';
|
|
|
10
11
|
import type { TunnelPort } from './edge-tunnels.js';
|
|
11
12
|
import type { ZonePort } from './edge-zones.js';
|
|
12
13
|
import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
|
|
14
|
+
import { type ReleaseContext, type ReleaseHost } from './release-host.js';
|
|
13
15
|
export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
14
16
|
import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
|
|
15
17
|
import { type OikosIngressPorts } from './oikos-ingress.js';
|
|
@@ -349,6 +351,14 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
|
|
|
349
351
|
private onPlatform;
|
|
350
352
|
/** `deps` with its Coolify port placed on the platform destination, then limited by `profile`. */
|
|
351
353
|
private limitedOnPlatform;
|
|
354
|
+
/** Every Coolify server and why platform-archon's blue/green cannot run here, for the planner. */
|
|
355
|
+
archonBlueGreen(): Promise<{
|
|
356
|
+
servers?: BlueGreenServer[];
|
|
357
|
+
blocked?: string;
|
|
358
|
+
}>;
|
|
359
|
+
/** The release runner's outside calls for platform-archon (`plan/release-host.ts`). The token is only read. */
|
|
360
|
+
releaseHost(platform: DeclaredPlatform, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
|
|
361
|
+
private archonBlueGreenStep;
|
|
352
362
|
blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>, owner?: PlatformOwnerInput): Promise<{
|
|
353
363
|
auth?: string;
|
|
354
364
|
authExecutorMissing?: boolean;
|
|
@@ -896,7 +906,9 @@ export declare function ownerInviteBlockedReason(ownerCompany: DeclaredOrg | nul
|
|
|
896
906
|
* {@link PlatformStepExecutionContext}, bundled like {@link PlatformAuthDeps}.
|
|
897
907
|
*/
|
|
898
908
|
export interface PlatformArchonDeps {
|
|
899
|
-
coolify: Pick<CoolifyPort, 'findProjectByName' | 'listEnvironments' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment'
|
|
909
|
+
coolify: Pick<CoolifyPort, 'findProjectByName' | 'listEnvironments' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage'> & {
|
|
910
|
+
listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
|
|
911
|
+
};
|
|
900
912
|
/** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
|
|
901
913
|
dns: PlatformDnsFactory;
|
|
902
914
|
/** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
|
|
@@ -972,6 +984,8 @@ export interface PlatformArchonRuntime {
|
|
|
972
984
|
readyTimeoutMs?: number;
|
|
973
985
|
/** Pause between readiness polls (default 5 seconds). */
|
|
974
986
|
readyIntervalMs?: number;
|
|
987
|
+
/** Called with every secret the blue/green steps read or mint (the health token, copied env), so the host can redact it. */
|
|
988
|
+
onSecret?: (value: string) => void;
|
|
975
989
|
}
|
|
976
990
|
/**
|
|
977
991
|
* The name of foundry's own Cloudflare Access app in front of Archon. Same
|