@wtfalch/foundry 0.42.1 → 0.43.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 +5 -1
- package/dist/index.js +3 -1
- package/dist/migrations/0055_release_requests.sql +20 -0
- package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
- package/dist/org.d.ts +13 -0
- package/dist/org.js +38 -1
- 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/cloudflared-connector.d.ts +26 -0
- package/dist/plan/cloudflared-connector.js +37 -0
- package/dist/plan/diff.d.ts +6 -0
- package/dist/plan/diff.js +31 -2
- package/dist/plan/executor.d.ts +34 -0
- package/dist/plan/executor.js +244 -9
- package/dist/plan/plan-apply.d.ts +51 -0
- package/dist/plan/plan-apply.js +222 -0
- 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 +12 -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/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/store/postgres.d.ts +8 -0
- package/dist/store/postgres.js +56 -3
- package/dist/store/schema.d.ts +154 -0
- package/dist/store/schema.js +12 -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';
|
|
@@ -25,6 +26,8 @@ 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';
|
|
28
31
|
import { liveWorkers, workerTraceScopeKeys } from './worker.js';
|
|
29
32
|
/** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
|
|
30
33
|
const companyScope = (orgId) => `company:${orgId}`;
|
|
@@ -119,6 +122,9 @@ export class PlatformConfirmationRequiredError extends Error {
|
|
|
119
122
|
/** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
|
|
120
123
|
const HOSTNAME_STEPS = new Set([
|
|
121
124
|
'coolify:app',
|
|
125
|
+
'coolify:bluegreen',
|
|
126
|
+
'coolify:bluegreen-lb',
|
|
127
|
+
'coolify:bluegreen-connector',
|
|
122
128
|
'coolify:broker-entry',
|
|
123
129
|
'coolify:database',
|
|
124
130
|
'cloudflare:tunnel',
|
|
@@ -167,6 +173,7 @@ export class FoundryPlanApply {
|
|
|
167
173
|
backupGate;
|
|
168
174
|
platformDeps;
|
|
169
175
|
runner;
|
|
176
|
+
releaseStore;
|
|
170
177
|
constructor(options) {
|
|
171
178
|
this.store = options.store;
|
|
172
179
|
this.readers = options.readers;
|
|
@@ -195,6 +202,7 @@ export class FoundryPlanApply {
|
|
|
195
202
|
this.backupGate = options.backupGate;
|
|
196
203
|
this.platformDeps = options.platform;
|
|
197
204
|
this.runner = options.runner;
|
|
205
|
+
this.releaseStore = options.releaseStore;
|
|
198
206
|
}
|
|
199
207
|
async plan(id) {
|
|
200
208
|
const { plan } = await this.computeFreshPlan(id);
|
|
@@ -953,6 +961,13 @@ export class FoundryPlanApply {
|
|
|
953
961
|
}
|
|
954
962
|
checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
|
|
955
963
|
}
|
|
964
|
+
// The steps made the structure; the release runner then moves traffic on it, on the same live ids.
|
|
965
|
+
await this.runOrgReleases(org, {
|
|
966
|
+
recordedIds: context.recordedIds,
|
|
967
|
+
recordScopeId: context.recordScopeId,
|
|
968
|
+
forgetId: (key) => context.forgetId?.(key),
|
|
969
|
+
persist: context.persistScopeIds,
|
|
970
|
+
}, fence);
|
|
956
971
|
}
|
|
957
972
|
catch (e) {
|
|
958
973
|
// #9 (resolved): "it stops at the checkpoint... there is no automatic
|
|
@@ -1012,6 +1027,135 @@ export class FoundryPlanApply {
|
|
|
1012
1027
|
});
|
|
1013
1028
|
return applied;
|
|
1014
1029
|
}
|
|
1030
|
+
/**
|
|
1031
|
+
* ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
|
|
1032
|
+
* (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
|
|
1033
|
+
* before this returns.
|
|
1034
|
+
*/
|
|
1035
|
+
async runOrgReleases(org, context, fence) {
|
|
1036
|
+
const store = this.releaseStore;
|
|
1037
|
+
const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
|
|
1038
|
+
if (!store || !this.executor.releaseHost || !primaryUuid)
|
|
1039
|
+
return;
|
|
1040
|
+
for (const entry of org.hosting.entries) {
|
|
1041
|
+
const subject = entrySubject(org, entry, context.recordedIds());
|
|
1042
|
+
if (!subject)
|
|
1043
|
+
continue;
|
|
1044
|
+
const host = this.executor.releaseHost(org, subject, context, () => this.renewLease(fence));
|
|
1045
|
+
if (!host)
|
|
1046
|
+
continue;
|
|
1047
|
+
await runSubjectRelease({
|
|
1048
|
+
store,
|
|
1049
|
+
scopeOwnerId: org.id,
|
|
1050
|
+
subject,
|
|
1051
|
+
host,
|
|
1052
|
+
context,
|
|
1053
|
+
now: this.now(),
|
|
1054
|
+
primaryUuid,
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
|
|
1059
|
+
async runPlatformReleases(platform, context, fence) {
|
|
1060
|
+
const store = this.releaseStore;
|
|
1061
|
+
const executor = this.platformDeps?.executor;
|
|
1062
|
+
const subject = archonSubject(platform, ARCHON_REPO);
|
|
1063
|
+
const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
|
|
1064
|
+
if (!store || !executor?.releaseHost || !subject || !primaryUuid)
|
|
1065
|
+
return;
|
|
1066
|
+
const host = executor.releaseHost(platform, subject, context, () => this.renewLease(fence));
|
|
1067
|
+
if (!host)
|
|
1068
|
+
return;
|
|
1069
|
+
await runSubjectRelease({
|
|
1070
|
+
store,
|
|
1071
|
+
scopeOwnerId: 'platform',
|
|
1072
|
+
subject,
|
|
1073
|
+
host,
|
|
1074
|
+
context,
|
|
1075
|
+
now: this.now(),
|
|
1076
|
+
primaryUuid,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
async runPlatformReleaseTick() {
|
|
1080
|
+
const deps = this.platformDeps;
|
|
1081
|
+
const platform = await deps?.store.get();
|
|
1082
|
+
if (!deps || !platform)
|
|
1083
|
+
return;
|
|
1084
|
+
const empty = { orgId: platform.ownerOrgId, steps: [], unmanaged: [] };
|
|
1085
|
+
if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
|
|
1086
|
+
await this.refusePlatform(empty, platform.ownerOrgId, 'not authorized to run the release tick');
|
|
1087
|
+
throw new NotAuthorizedError(this.actor, platform.ownerOrgId);
|
|
1088
|
+
}
|
|
1089
|
+
await this.withMutationLease(PLATFORM_SCOPE, (reason) => this.refusePlatform(empty, platform.ownerOrgId, reason), async (fence) => {
|
|
1090
|
+
const serviceIds = { ...(await deps.store.getServiceIds()) };
|
|
1091
|
+
await this.runPlatformReleases(platform, {
|
|
1092
|
+
recordedIds: () => ({ ...serviceIds }),
|
|
1093
|
+
recordScopeId: (key, value) => {
|
|
1094
|
+
serviceIds[key] = value;
|
|
1095
|
+
},
|
|
1096
|
+
forgetId: (key) => {
|
|
1097
|
+
delete serviceIds[key];
|
|
1098
|
+
},
|
|
1099
|
+
persist: () => deps.store.putServiceIds({ ...serviceIds }, fence),
|
|
1100
|
+
}, fence);
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
|
|
1105
|
+
* archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
|
|
1106
|
+
* no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
|
|
1107
|
+
* apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
|
|
1108
|
+
* A refusal is audited; a run that does nothing writes no audit row.
|
|
1109
|
+
*/
|
|
1110
|
+
async runReleaseTick() {
|
|
1111
|
+
const results = [];
|
|
1112
|
+
for (const org of await this.store.list()) {
|
|
1113
|
+
if (!org.hosting.entries.some((entry) => entry.blueGreen))
|
|
1114
|
+
continue;
|
|
1115
|
+
try {
|
|
1116
|
+
await this.runOrgReleaseTick(org.id);
|
|
1117
|
+
results.push({ target: org.id });
|
|
1118
|
+
}
|
|
1119
|
+
catch (e) {
|
|
1120
|
+
results.push({ target: org.id, error: e instanceof Error ? e.message : String(e) });
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const platform = await this.platformDeps?.store.get();
|
|
1124
|
+
if (platform?.archon.enabled && platform.archon.blueGreen) {
|
|
1125
|
+
try {
|
|
1126
|
+
await this.runPlatformReleaseTick();
|
|
1127
|
+
results.push({ target: 'platform' });
|
|
1128
|
+
}
|
|
1129
|
+
catch (e) {
|
|
1130
|
+
results.push({ target: 'platform', error: e instanceof Error ? e.message : String(e) });
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return results;
|
|
1134
|
+
}
|
|
1135
|
+
async runOrgReleaseTick(id) {
|
|
1136
|
+
const empty = { orgId: id, steps: [], unmanaged: [] };
|
|
1137
|
+
if (!(await this.authz.canApply(this.actor, id))) {
|
|
1138
|
+
await this.refuse(empty, 'not authorized to run the release tick');
|
|
1139
|
+
throw new NotAuthorizedError(this.actor, id);
|
|
1140
|
+
}
|
|
1141
|
+
await this.withMutationLease(companyScope(id), (reason) => this.refuse(empty, reason), async (fence) => {
|
|
1142
|
+
const org = await this.store.get(id);
|
|
1143
|
+
const previous = await this.store.getAppliedState(id);
|
|
1144
|
+
if (!org || !previous)
|
|
1145
|
+
return;
|
|
1146
|
+
const serviceIds = { ...previous.serviceIds };
|
|
1147
|
+
await this.runOrgReleases(org, {
|
|
1148
|
+
recordedIds: () => ({ ...serviceIds }),
|
|
1149
|
+
recordScopeId: (key, value) => {
|
|
1150
|
+
serviceIds[key] = value;
|
|
1151
|
+
},
|
|
1152
|
+
forgetId: (key) => {
|
|
1153
|
+
delete serviceIds[key];
|
|
1154
|
+
},
|
|
1155
|
+
persist: () => this.store.putAppliedState({ ...previous, serviceIds: { ...serviceIds } }, fence),
|
|
1156
|
+
}, fence);
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1015
1159
|
/** 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
1160
|
async readSfuPortsOpen(org) {
|
|
1017
1161
|
const slot = org.services?.sfuSlot;
|
|
@@ -1048,6 +1192,10 @@ export class FoundryPlanApply {
|
|
|
1048
1192
|
: false;
|
|
1049
1193
|
const sfuPortsOpen = await this.readSfuPortsOpen(org);
|
|
1050
1194
|
const resumable = await this.ownResumableRefs(org);
|
|
1195
|
+
// Read only for an org that declares it; a failed read plans none and says so (`planBlueGreen`).
|
|
1196
|
+
const blueGreenServers = org.hosting.entries.some((e) => e.blueGreen) && this.executor.blueGreenServers
|
|
1197
|
+
? await this.executor.blueGreenServers().catch(() => undefined)
|
|
1198
|
+
: undefined;
|
|
1051
1199
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
1052
1200
|
loginHostBindings: this.loginHostBindings,
|
|
1053
1201
|
loginHostServesDiscovery,
|
|
@@ -1066,6 +1214,18 @@ export class FoundryPlanApply {
|
|
|
1066
1214
|
oikosZoneReady: platformReadiness.oikosIngressApplied === true,
|
|
1067
1215
|
ownMailKeyHeld: await this.ownMailKeyHeld(org),
|
|
1068
1216
|
ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
|
|
1217
|
+
...(blueGreenServers ? { blueGreenServers } : {}),
|
|
1218
|
+
...(blueGreenServers && org.hosting.entries.some((e) => e.blueGreen && e.database)
|
|
1219
|
+
? {
|
|
1220
|
+
remoteDatabase: remoteDatabaseFactsFrom({
|
|
1221
|
+
org,
|
|
1222
|
+
servers: blueGreenServers,
|
|
1223
|
+
recorded,
|
|
1224
|
+
access: !platformReadiness.dnsBlocked ||
|
|
1225
|
+
['dns', 'zones', 'access', 'tunnels'].every((port) => platformReadiness.ownEdgePorts?.[port]),
|
|
1226
|
+
}),
|
|
1227
|
+
}
|
|
1228
|
+
: {}),
|
|
1069
1229
|
ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
|
|
1070
1230
|
ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
|
|
1071
1231
|
...(this.backupGate
|
|
@@ -1391,8 +1551,21 @@ export class FoundryPlanApply {
|
|
|
1391
1551
|
.map((org) => org.slug);
|
|
1392
1552
|
const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs() : null;
|
|
1393
1553
|
const owner = await this.platformOwnerInput(platform);
|
|
1554
|
+
const archonBlueGreen = platform.archon.enabled && platform.archon.blueGreen && executor.archonBlueGreen
|
|
1555
|
+
? await executor.archonBlueGreen().catch(() => ({ servers: undefined }))
|
|
1556
|
+
: null;
|
|
1394
1557
|
const detail = computePlatformPlanDetail(platform, live, {
|
|
1395
1558
|
owner,
|
|
1559
|
+
...(archonBlueGreen
|
|
1560
|
+
? {
|
|
1561
|
+
archonBlueGreen: {
|
|
1562
|
+
servers: archonBlueGreen.servers,
|
|
1563
|
+
...('blocked' in archonBlueGreen && archonBlueGreen.blocked
|
|
1564
|
+
? { blocked: archonBlueGreen.blocked }
|
|
1565
|
+
: {}),
|
|
1566
|
+
},
|
|
1567
|
+
}
|
|
1568
|
+
: {}),
|
|
1396
1569
|
jobsObservable: readers.coolify !== undefined,
|
|
1397
1570
|
sfuBlocked,
|
|
1398
1571
|
callsOrgSlugs,
|
|
@@ -1515,6 +1688,14 @@ export class FoundryPlanApply {
|
|
|
1515
1688
|
serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
|
|
1516
1689
|
}
|
|
1517
1690
|
}
|
|
1691
|
+
await this.runPlatformReleases(platform, {
|
|
1692
|
+
recordedIds: context.recordedIds,
|
|
1693
|
+
recordScopeId: context.recordScopeId,
|
|
1694
|
+
forgetId: (key) => {
|
|
1695
|
+
delete serviceIds[key];
|
|
1696
|
+
},
|
|
1697
|
+
persist: () => store.putServiceIds(serviceIds, fence),
|
|
1698
|
+
}, fence);
|
|
1518
1699
|
}
|
|
1519
1700
|
catch (e) {
|
|
1520
1701
|
// Whatever completed stays recorded, as an org's apply does. A lost
|
|
@@ -1630,6 +1811,10 @@ function destroyTargets(serviceIds, orgId) {
|
|
|
1630
1811
|
}
|
|
1631
1812
|
continue;
|
|
1632
1813
|
}
|
|
1814
|
+
// Blue/green: the colors, balancers, stage, database sidecars and extra connectors, by recorded id, before
|
|
1815
|
+
// the main database they connect to. Their keys carry no ':' (scope ids), so no prefix above finds them.
|
|
1816
|
+
if (prefix === 'coolify:database:')
|
|
1817
|
+
targets.push(...blueGreenDestroyTargets(serviceIds));
|
|
1633
1818
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
1634
1819
|
if (!resourceKey.startsWith(prefix) || !id)
|
|
1635
1820
|
continue;
|
|
@@ -1745,10 +1930,44 @@ function destroyStepDescriptions(targets) {
|
|
|
1745
1930
|
'delete the declared org',
|
|
1746
1931
|
];
|
|
1747
1932
|
}
|
|
1933
|
+
/** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
|
|
1934
|
+
const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
|
|
1935
|
+
const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
|
|
1936
|
+
/** 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). */
|
|
1937
|
+
const BLUE_GREEN_EDGE_KEYS = [
|
|
1938
|
+
[/^bgdb\.[^.:]+\.access$/, 'bg-access'],
|
|
1939
|
+
[/^bgdb\.[^.:]+\.(token|undelivered)$/, 'bg-token'],
|
|
1940
|
+
[/^bgdb\.[^.:]+\.route$/, 'bg-dns'],
|
|
1941
|
+
];
|
|
1942
|
+
/** Every recorded blue/green app (then stage's database), in key order. A destroy target's `key` is the scope key itself. */
|
|
1943
|
+
function blueGreenDestroyTargets(serviceIds) {
|
|
1944
|
+
const keys = Object.keys(serviceIds).sort();
|
|
1945
|
+
const targets = [];
|
|
1946
|
+
for (const key of keys.filter((k) => BLUE_GREEN_APP_KEY.test(k))) {
|
|
1947
|
+
const id = serviceIds[key];
|
|
1948
|
+
if (id)
|
|
1949
|
+
targets.push({ system: 'coolify', kind: 'bg-app', key, id });
|
|
1950
|
+
}
|
|
1951
|
+
for (const key of keys.filter((k) => BLUE_GREEN_DATABASE_KEY.test(k))) {
|
|
1952
|
+
const id = serviceIds[key];
|
|
1953
|
+
if (id)
|
|
1954
|
+
targets.push({ system: 'coolify', kind: 'bg-database', key, id });
|
|
1955
|
+
}
|
|
1956
|
+
for (const [pattern, kind] of BLUE_GREEN_EDGE_KEYS) {
|
|
1957
|
+
for (const key of keys.filter((k) => pattern.test(k))) {
|
|
1958
|
+
const id = serviceIds[key];
|
|
1959
|
+
if (id)
|
|
1960
|
+
targets.push({ system: 'cloudflare', kind, key, id });
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
return targets;
|
|
1964
|
+
}
|
|
1748
1965
|
/** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
|
|
1749
1966
|
function destroyServiceIdKey(target) {
|
|
1750
1967
|
if (target.kind === 'project' || target.kind === 'org')
|
|
1751
1968
|
return target.key;
|
|
1969
|
+
if (target.kind.startsWith('bg-'))
|
|
1970
|
+
return target.key;
|
|
1752
1971
|
if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
|
|
1753
1972
|
return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
|
|
1754
1973
|
}
|
|
@@ -1781,6 +2000,9 @@ const STEP_FIELDS = {
|
|
|
1781
2000
|
'ai:key': 'ai',
|
|
1782
2001
|
'cms:site': 'site',
|
|
1783
2002
|
'coolify:app': 'hosting.enabled',
|
|
2003
|
+
'coolify:bluegreen': 'hosting.entries.blueGreen',
|
|
2004
|
+
'coolify:bluegreen-lb': 'hosting.entries.blueGreen',
|
|
2005
|
+
'coolify:bluegreen-connector': 'hosting.entries.blueGreen',
|
|
1784
2006
|
'coolify:database': 'hosting.database',
|
|
1785
2007
|
'cloudflare:tunnel': 'hosting.tunnel',
|
|
1786
2008
|
'cloudflare:zone': 'hosting.usesDomain',
|
|
@@ -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
|
|
@@ -3,18 +3,21 @@ import { isIPv4 } from 'node:net';
|
|
|
3
3
|
import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
|
|
4
4
|
import { VERSIONS, coolifyImageFor, floatingTagBlocked, pinnedRef } from '../bootstrap/versions.js';
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
|
-
import { ownerMailboxes } from '../org.js';
|
|
6
|
+
import { ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
7
|
+
import { applyArchonBlueGreen } from './archon-blue-green.js';
|
|
8
|
+
import { ARCHON_REPO, archonSubject, healthTokenName, tunnelOriginFor, } from './blue-green.js';
|
|
7
9
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
|
|
8
10
|
import { recordBuiltCommit } from './build-record.js';
|
|
9
11
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
10
12
|
import { previewsOn } from './coolify-previews.js';
|
|
11
13
|
import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
12
14
|
import { placedOnPlatform } from './destinations.js';
|
|
13
|
-
import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
15
|
+
import { ACCESS_POLICY_NAME, LOG_DIR, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
14
16
|
import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
|
|
15
17
|
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
|
|
16
18
|
import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
17
19
|
import { LIMITS, limited } from './own-limits.js';
|
|
20
|
+
import { createReleaseHost } from './release-host.js';
|
|
18
21
|
export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
19
22
|
import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
|
|
20
23
|
import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
|
|
@@ -173,6 +176,86 @@ export class FoundryPlatformStepExecutor {
|
|
|
173
176
|
limitedOnPlatform(deps, profile) {
|
|
174
177
|
return { ...deps, coolify: limited(this.onPlatform(deps.coolify), profile) };
|
|
175
178
|
}
|
|
179
|
+
/** Every Coolify server and why platform-archon's blue/green cannot run here, for the planner. */
|
|
180
|
+
async archonBlueGreen() {
|
|
181
|
+
const archon = this.archon;
|
|
182
|
+
if (!archon)
|
|
183
|
+
return { blocked: 'the host built the platform executor without archon dependencies' };
|
|
184
|
+
if (typeof archon.heldKeys?.put !== 'function') {
|
|
185
|
+
return { blocked: 'the host cannot hold the health token (its held keys cannot put)' };
|
|
186
|
+
}
|
|
187
|
+
const servers = await archon.coolify.listServers();
|
|
188
|
+
return {
|
|
189
|
+
servers: servers.map((s) => ({
|
|
190
|
+
uuid: s.uuid,
|
|
191
|
+
usable: s.is_usable,
|
|
192
|
+
reachable: s.is_reachable,
|
|
193
|
+
})),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** The release runner's outside calls for platform-archon (`plan/release-host.ts`). The token is only read. */
|
|
197
|
+
releaseHost(platform, subject, context, renew) {
|
|
198
|
+
const archon = this.archon;
|
|
199
|
+
const recorded = context.recordedIds();
|
|
200
|
+
const primaryBlueAppId = recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`];
|
|
201
|
+
const primaryUuid = recorded.coolify_server;
|
|
202
|
+
if (!archon || !primaryBlueAppId || !primaryUuid)
|
|
203
|
+
return null;
|
|
204
|
+
return createReleaseHost({
|
|
205
|
+
subject,
|
|
206
|
+
context,
|
|
207
|
+
coolify: archon.coolify,
|
|
208
|
+
healthToken: async () => (await archon.heldKeys?.resolve(sharedSecretRef(platform.ownerOrgId, healthTokenName(subject.id)))) ?? null,
|
|
209
|
+
secret: (value) => archon.runtime?.onSecret?.(value),
|
|
210
|
+
primaryUuid,
|
|
211
|
+
primaryBlueAppId,
|
|
212
|
+
runtime: this.runtime,
|
|
213
|
+
...(archon.runtime?.fetch ? { fetch: archon.runtime.fetch } : {}),
|
|
214
|
+
renew,
|
|
215
|
+
label: `coolify:release:${subject.id}`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async archonBlueGreenStep(action, context) {
|
|
219
|
+
if (!this.archon) {
|
|
220
|
+
throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
|
|
221
|
+
}
|
|
222
|
+
const deps = this.limitedOnPlatform(this.archon, LIMITS.platformArchon);
|
|
223
|
+
const platform = context.platform;
|
|
224
|
+
const subject = archonSubject(platform, ARCHON_REPO);
|
|
225
|
+
const recorded = context.recordedIds();
|
|
226
|
+
const blueAppId = recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`];
|
|
227
|
+
const projectUuid = recorded[PLATFORM_ARCHON_PROJECT_SCOPE_KEY];
|
|
228
|
+
const tunnelId = recorded[PLATFORM_ARCHON_TUNNEL_SCOPE_KEY];
|
|
229
|
+
if (!subject || !blueAppId || !projectUuid || !tunnelId) {
|
|
230
|
+
throw new Error('coolify:archon-bluegreen: platform-archon is not applied yet');
|
|
231
|
+
}
|
|
232
|
+
if (typeof deps.heldKeys?.put !== 'function') {
|
|
233
|
+
throw new Error('coolify:archon-bluegreen: the host cannot hold the health token');
|
|
234
|
+
}
|
|
235
|
+
await applyArchonBlueGreen({
|
|
236
|
+
subject,
|
|
237
|
+
ownerOrgId: platform.ownerOrgId,
|
|
238
|
+
desired: { primary: String(action.desired.primary ?? '') },
|
|
239
|
+
recorded: () => context.recordedIds(),
|
|
240
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
241
|
+
persist: async () => {
|
|
242
|
+
await context.persist?.();
|
|
243
|
+
},
|
|
244
|
+
coolify: deps.coolify,
|
|
245
|
+
heldKeys: { resolve: deps.heldKeys.resolve, put: deps.heldKeys.put },
|
|
246
|
+
secret: (value) => deps.runtime?.onSecret?.(value),
|
|
247
|
+
projectUuid,
|
|
248
|
+
blueAppId,
|
|
249
|
+
tunnelId,
|
|
250
|
+
hostname: platform.archon.hostname,
|
|
251
|
+
tunnels: await platformTunnels(deps, context.recordedIds(), 'coolify:archon-bluegreen'),
|
|
252
|
+
runtime: deps.runtime ?? {},
|
|
253
|
+
sleep: deps.runtime?.sleep ?? defaultSleep,
|
|
254
|
+
logDir: LOG_DIR,
|
|
255
|
+
isRunning: isRunningOrStarting,
|
|
256
|
+
});
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
176
259
|
async blockedSteps(platform, recordedIds = {}, owner = {}) {
|
|
177
260
|
const blocked = {};
|
|
178
261
|
if (platform.auth.enabled && !this.auth) {
|
|
@@ -724,6 +807,9 @@ export class FoundryPlatformStepExecutor {
|
|
|
724
807
|
}
|
|
725
808
|
return provisionPlatformEdge(context, this.limitedOnPlatform(this.edge, LIMITS.platformEdge));
|
|
726
809
|
}
|
|
810
|
+
if (action.system === 'coolify' && action.kind === 'archon-bluegreen') {
|
|
811
|
+
return this.archonBlueGreenStep(action, context);
|
|
812
|
+
}
|
|
727
813
|
if (action.system === 'coolify' &&
|
|
728
814
|
action.kind === 'app' &&
|
|
729
815
|
action.key === PLATFORM_ARCHON_KEY) {
|
|
@@ -3602,8 +3688,6 @@ const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
|
|
|
3602
3688
|
const PLATFORM_ARCHON_TUNNEL_NAME = 'platform-archon-tunnel';
|
|
3603
3689
|
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_ARCHON_TUNNEL_NAME}. */
|
|
3604
3690
|
const PLATFORM_ARCHON_CLOUDFLARED_APP_NAME = 'platform-archon-cloudflared';
|
|
3605
|
-
/** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
|
|
3606
|
-
const ARCHON_REPO = 'wtfalch/archon';
|
|
3607
3691
|
const ARCHON_PORT = '3000';
|
|
3608
3692
|
/**
|
|
3609
3693
|
* #480: Archon's own health route (`@wtfalch/reporting`'s `healthHandler`,
|
|
@@ -4506,7 +4590,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
4506
4590
|
ingress: [
|
|
4507
4591
|
{
|
|
4508
4592
|
hostname,
|
|
4509
|
-
|
|
4593
|
+
// Through the balancer once platform-archon runs blue/green and its balancer is written.
|
|
4594
|
+
service: tunnelOriginFor(archonSubject(platform, ARCHON_REPO), recorded, recorded.coolify_server, `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}`),
|
|
4510
4595
|
},
|
|
4511
4596
|
{ service: 'http_status:404' },
|
|
4512
4597
|
],
|
|
@@ -47,12 +47,12 @@ export declare function entryLabelSpec(org: DeclaredOrg, entry: HostingEntry): E
|
|
|
47
47
|
* without a domain too: its `<slug>.oikos.host` name is routed by the company's own tunnel
|
|
48
48
|
* ({@link entryOikosHostnames}), not by the platform's shared ingress.
|
|
49
49
|
*/
|
|
50
|
-
export declare function entryRoutes(org: DeclaredOrg): Array<{
|
|
50
|
+
export declare function entryRoutes(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): Array<{
|
|
51
51
|
hostname: string;
|
|
52
52
|
service: string;
|
|
53
53
|
}>;
|
|
54
54
|
/** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
|
|
55
|
-
export declare function entryRoutesMarker(org: DeclaredOrg): string;
|
|
55
|
+
export declare function entryRoutesMarker(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string;
|
|
56
56
|
/**
|
|
57
57
|
* A short digest of the spec; the same spec always gives the same hash. The token is left out: the
|
|
58
58
|
* planner has none, and a changed token reaches a live app through the comparison of its live labels.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { PRIMARY_SERVER_SCOPE_KEY, balancerApplied, lbAppName, liveAppNameFor, } from './blue-green.js';
|
|
2
3
|
import { entryHostnames, wildcardHostname } from './hostnames.js';
|
|
3
4
|
import { NO_DOMAIN_ZONE } from './own-hostnames.js';
|
|
4
5
|
import { appNameFor } from './platform-diff.js';
|
|
@@ -56,13 +57,20 @@ export function entryLabelSpec(org, entry) {
|
|
|
56
57
|
* without a domain too: its `<slug>.oikos.host` name is routed by the company's own tunnel
|
|
57
58
|
* ({@link entryOikosHostnames}), not by the platform's shared ingress.
|
|
58
59
|
*/
|
|
59
|
-
export function entryRoutes(org) {
|
|
60
|
+
export function entryRoutes(org, recorded = {}) {
|
|
60
61
|
if (!org.hosting.enabled)
|
|
61
62
|
return [];
|
|
62
63
|
const routes = [];
|
|
63
64
|
let wildcard;
|
|
64
65
|
for (const entry of org.hosting.entries) {
|
|
65
|
-
|
|
66
|
+
// A blue/green entry is reached through its balancer, which each server's connector finds on its own
|
|
67
|
+
// network, once the balancer exists on the primary. Until then it keeps the origin it had.
|
|
68
|
+
const target = !entry.blueGreen
|
|
69
|
+
? appNameFor(org, entry)
|
|
70
|
+
: balancerApplied(entry.key, recorded, recorded[PRIMARY_SERVER_SCOPE_KEY])
|
|
71
|
+
? lbAppName(appNameFor(org, entry))
|
|
72
|
+
: liveAppNameFor(org, entry, recorded);
|
|
73
|
+
const service = `http://${target}:${entryLabelSpec(org, entry).port}`;
|
|
66
74
|
for (const hostname of entryHostnames(org, entry)) {
|
|
67
75
|
if (!routes.some((r) => r.hostname === hostname))
|
|
68
76
|
routes.push({ hostname, service });
|
|
@@ -76,8 +84,8 @@ export function entryRoutes(org) {
|
|
|
76
84
|
return routes;
|
|
77
85
|
}
|
|
78
86
|
/** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
|
|
79
|
-
export function entryRoutesMarker(org) {
|
|
80
|
-
return JSON.stringify(entryRoutes(org));
|
|
87
|
+
export function entryRoutesMarker(org, recorded = {}) {
|
|
88
|
+
return JSON.stringify(entryRoutes(org, recorded));
|
|
81
89
|
}
|
|
82
90
|
/**
|
|
83
91
|
* A short digest of the spec; the same spec always gives the same hash. The token is left out: the
|