@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/executor.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
|
|
|
2
2
|
import { type CallsSfuUrls } from '../calls-sfu.js';
|
|
3
3
|
import type { DeclaredOrg, HostingBuildPack } from '../org.js';
|
|
4
4
|
import type { DeclaredOrgStore } from '../store.js';
|
|
5
|
+
import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
|
|
5
6
|
import { type BrokerPort } from './broker-entries.js';
|
|
6
7
|
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
7
8
|
import { type StepAction } from './diff.js';
|
|
@@ -16,6 +17,7 @@ import { type OwnServicesPort } from './own-services.js';
|
|
|
16
17
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
17
18
|
import type { EdgePortName } from './platform-org.js';
|
|
18
19
|
import type { RequestHeaderPort } from './platform-providers.js';
|
|
20
|
+
import { type ReleaseContext, type ReleaseHost } from './release-host.js';
|
|
19
21
|
export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
|
|
20
22
|
/**
|
|
21
23
|
* The Coolify calls this executor makes, shaped to match
|
|
@@ -103,6 +105,8 @@ export interface CoolifyPort {
|
|
|
103
105
|
*/
|
|
104
106
|
getApplication(uuid: string): Promise<{
|
|
105
107
|
uuid: string;
|
|
108
|
+
/** Coolify's name for the app; a blue/green entry's recorded app is its blue copy only when this is the blue name. */
|
|
109
|
+
name?: string;
|
|
106
110
|
status?: string;
|
|
107
111
|
custom_labels?: string | null;
|
|
108
112
|
settings?: {
|
|
@@ -2141,6 +2145,34 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2141
2145
|
* actually be created on.
|
|
2142
2146
|
*/
|
|
2143
2147
|
private resolveServer;
|
|
2148
|
+
/**
|
|
2149
|
+
* Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
|
|
2150
|
+
* {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
|
|
2151
|
+
* down is named in `down` and left out of `servers`; it is not an error.
|
|
2152
|
+
*/
|
|
2153
|
+
resolveServers(org: DeclaredOrg, context: StepExecutionContext): Promise<{
|
|
2154
|
+
primary: {
|
|
2155
|
+
uuid: string;
|
|
2156
|
+
};
|
|
2157
|
+
servers: Array<{
|
|
2158
|
+
uuid: string;
|
|
2159
|
+
}>;
|
|
2160
|
+
down: string[];
|
|
2161
|
+
}>;
|
|
2162
|
+
/**
|
|
2163
|
+
* The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
|
|
2164
|
+
* blue copy is not recorded. The health token is only read here, never minted.
|
|
2165
|
+
*/
|
|
2166
|
+
releaseHost(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
|
|
2167
|
+
/** Every server Coolify lists with whether it is usable and reachable, for the planner. */
|
|
2168
|
+
blueGreenServers(): Promise<BlueGreenServer[]>;
|
|
2169
|
+
/**
|
|
2170
|
+
* The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
|
|
2171
|
+
* `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
|
|
2172
|
+
* server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
|
|
2173
|
+
* a step for it fails, which the next plan reports instead.
|
|
2174
|
+
*/
|
|
2175
|
+
private coolifyBlueGreen;
|
|
2144
2176
|
private coolifyApp;
|
|
2145
2177
|
/**
|
|
2146
2178
|
* #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
|
|
@@ -3009,6 +3041,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
3009
3041
|
*/
|
|
3010
3042
|
private ownSfu;
|
|
3011
3043
|
private jobsRegistration;
|
|
3044
|
+
/** The blue/green database route (`plan/remote-database.ts`). */
|
|
3045
|
+
private remoteDatabaseRoute;
|
|
3012
3046
|
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
3013
3047
|
private deliverWithRetry;
|
|
3014
3048
|
private cloudflareTunnel;
|
package/dist/plan/executor.js
CHANGED
|
@@ -7,6 +7,8 @@ import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
|
7
7
|
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
8
8
|
import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
|
|
9
9
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
10
|
+
import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
|
|
11
|
+
import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
|
|
10
12
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
11
13
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
12
14
|
import { recordBuiltCommit } from './build-record.js';
|
|
@@ -44,6 +46,8 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
|
|
|
44
46
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
45
47
|
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
46
48
|
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
49
|
+
import { createReleaseHost } from './release-host.js';
|
|
50
|
+
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
47
51
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
48
52
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
49
53
|
import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
|
|
@@ -883,7 +887,11 @@ function limitProfileOf(action) {
|
|
|
883
887
|
case 'coolify:boule':
|
|
884
888
|
case 'coolify:boule-broker-entry':
|
|
885
889
|
return LIMITS.boule;
|
|
890
|
+
case 'coolify:bluegreen':
|
|
891
|
+
case 'coolify:bluegreen-lb':
|
|
892
|
+
return LIMITS.companyApp;
|
|
886
893
|
case 'cloudflare:tunnel':
|
|
894
|
+
case 'coolify:bluegreen-connector':
|
|
887
895
|
return LIMITS.connector;
|
|
888
896
|
default:
|
|
889
897
|
return action.system === 'coolify' ? LIMITS.companyOther : undefined;
|
|
@@ -1109,6 +1117,10 @@ export class FoundryStepExecutor {
|
|
|
1109
1117
|
return this.coolifyApp(action, org, context);
|
|
1110
1118
|
case 'coolify:network':
|
|
1111
1119
|
return this.coolifyNetwork(action, org, context);
|
|
1120
|
+
case 'coolify:bluegreen':
|
|
1121
|
+
case 'coolify:bluegreen-lb':
|
|
1122
|
+
case 'coolify:bluegreen-connector':
|
|
1123
|
+
return this.coolifyBlueGreen(action, org, context);
|
|
1112
1124
|
case 'coolify:broker-entry':
|
|
1113
1125
|
return this.coolifyBrokerEntry(action, org, context);
|
|
1114
1126
|
case 'coolify:database':
|
|
@@ -1169,6 +1181,8 @@ export class FoundryStepExecutor {
|
|
|
1169
1181
|
return this.bouleBrokerEntry(org, context);
|
|
1170
1182
|
case 'jobs:registration':
|
|
1171
1183
|
return this.jobsRegistration(action, org, context);
|
|
1184
|
+
case 'cloudflare:db-route':
|
|
1185
|
+
return this.remoteDatabaseRoute(action, org, context);
|
|
1172
1186
|
case 'coolify:own-auth':
|
|
1173
1187
|
return this.ownAuth(action, org, context);
|
|
1174
1188
|
case 'coolify:own-auth-db-move':
|
|
@@ -2501,6 +2515,140 @@ export class FoundryStepExecutor {
|
|
|
2501
2515
|
context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
|
|
2502
2516
|
return server;
|
|
2503
2517
|
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
|
|
2520
|
+
* {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
|
|
2521
|
+
* down is named in `down` and left out of `servers`; it is not an error.
|
|
2522
|
+
*/
|
|
2523
|
+
async resolveServers(org, context) {
|
|
2524
|
+
const primary = await this.resolveServer(org, context);
|
|
2525
|
+
const { up, down } = splitServers(await this.blueGreenServers());
|
|
2526
|
+
return {
|
|
2527
|
+
primary,
|
|
2528
|
+
servers: [primary, ...up.filter((uuid) => uuid !== primary.uuid).map((uuid) => ({ uuid }))],
|
|
2529
|
+
down,
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
|
|
2534
|
+
* blue copy is not recorded. The health token is only read here, never minted.
|
|
2535
|
+
*/
|
|
2536
|
+
releaseHost(org, subject, context, renew) {
|
|
2537
|
+
const recorded = context.recordedIds();
|
|
2538
|
+
const primaryBlueAppId = recorded[`coolify:app:${appStepKey(org, subject.id)}`];
|
|
2539
|
+
const primaryUuid = recorded[COOLIFY_SERVER_SCOPE_KEY];
|
|
2540
|
+
if (!primaryBlueAppId || !primaryUuid)
|
|
2541
|
+
return null;
|
|
2542
|
+
return createReleaseHost({
|
|
2543
|
+
subject,
|
|
2544
|
+
context,
|
|
2545
|
+
coolify: this.ports.coolify,
|
|
2546
|
+
healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
|
|
2547
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
2548
|
+
primaryUuid,
|
|
2549
|
+
primaryBlueAppId,
|
|
2550
|
+
runtime: this.ports.runtime ?? {},
|
|
2551
|
+
...(this.ports.runtime?.fetch ? { fetch: this.ports.runtime.fetch } : {}),
|
|
2552
|
+
renew,
|
|
2553
|
+
label: `coolify:release:${subject.id}`,
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
/** Every server Coolify lists with whether it is usable and reachable, for the planner. */
|
|
2557
|
+
async blueGreenServers() {
|
|
2558
|
+
return (await this.ports.coolify.listServers()).map((s) => ({
|
|
2559
|
+
uuid: s.uuid,
|
|
2560
|
+
usable: s.is_usable,
|
|
2561
|
+
reachable: s.is_reachable,
|
|
2562
|
+
}));
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
|
|
2566
|
+
* `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
|
|
2567
|
+
* server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
|
|
2568
|
+
* a step for it fails, which the next plan reports instead.
|
|
2569
|
+
*/
|
|
2570
|
+
async coolifyBlueGreen(action, org, context) {
|
|
2571
|
+
const recorded = () => context.recordedIds();
|
|
2572
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2573
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2574
|
+
const serverUuid = String(action.desired.server ?? '');
|
|
2575
|
+
const primaryUuid = String(action.desired.primary ?? '');
|
|
2576
|
+
const live = await this.resolveServers(org, context);
|
|
2577
|
+
if (!live.servers.some((s) => s.uuid === serverUuid)) {
|
|
2578
|
+
throw new Error(`${action.kind} for ${org.slug}: Coolify server ${serverUuid} is not usable and reachable`);
|
|
2579
|
+
}
|
|
2580
|
+
if (live.primary.uuid !== primaryUuid) {
|
|
2581
|
+
throw new Error(`${action.kind} for ${org.slug}: the plan's primary server is not the recorded one`);
|
|
2582
|
+
}
|
|
2583
|
+
if (action.kind === 'bluegreen-connector') {
|
|
2584
|
+
const tunnelId = recorded()[`cloudflare:tunnel:${org.id}`];
|
|
2585
|
+
if (!tunnelId)
|
|
2586
|
+
throw new Error(`bluegreen-connector for ${org.slug}: the tunnel is not recorded yet`);
|
|
2587
|
+
await applyBlueGreenConnectors({
|
|
2588
|
+
coolify: this.ports.coolify,
|
|
2589
|
+
tunnels: this.tunnelPort(),
|
|
2590
|
+
tunnelId,
|
|
2591
|
+
baseAppName: `${org.slug}-cloudflared`,
|
|
2592
|
+
baseScopeKey: CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY,
|
|
2593
|
+
primaryUuid,
|
|
2594
|
+
serverUuids: [serverUuid],
|
|
2595
|
+
projectUuid,
|
|
2596
|
+
environment,
|
|
2597
|
+
recorded,
|
|
2598
|
+
record: (key, id) => context.recordScopeId(key, id),
|
|
2599
|
+
persist: () => context.persistScopeIds(),
|
|
2600
|
+
});
|
|
2601
|
+
return undefined;
|
|
2602
|
+
}
|
|
2603
|
+
const entry = org.hosting.entries.find((e) => e.key === action.desired.entry);
|
|
2604
|
+
if (!entry || !entrySubject(org, entry)) {
|
|
2605
|
+
throw new Error(`${action.kind} for ${org.slug}: no blue/green entry '${String(action.desired.entry)}'`);
|
|
2606
|
+
}
|
|
2607
|
+
const primaryBlueAppId = recorded()[`coolify:app:${appStepKey(org, entry.key)}`];
|
|
2608
|
+
if (!primaryBlueAppId) {
|
|
2609
|
+
throw new Error(`${action.kind} for ${org.slug}: entry '${entry.key}' has no blue app recorded yet`);
|
|
2610
|
+
}
|
|
2611
|
+
// The blue app keeps the name it has: an app that ran before blue/green is adopted under its own name.
|
|
2612
|
+
if (!recorded()[bgKey.blueName(entry.key)]) {
|
|
2613
|
+
const blue = await this.ports.coolify.getApplication(primaryBlueAppId);
|
|
2614
|
+
if (blue.name) {
|
|
2615
|
+
context.recordScopeId(bgKey.blueName(entry.key), blue.name);
|
|
2616
|
+
await context.persistScopeIds();
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
const subject = entrySubject(org, entry, recorded());
|
|
2620
|
+
const runtime = this.ports.runtime ?? {};
|
|
2621
|
+
const deps = {
|
|
2622
|
+
coolify: this.ports.coolify,
|
|
2623
|
+
recorded,
|
|
2624
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
2625
|
+
persist: () => context.persistScopeIds(),
|
|
2626
|
+
projectUuid,
|
|
2627
|
+
environment,
|
|
2628
|
+
healthToken: async () => {
|
|
2629
|
+
if (typeof this.ports.heldKeys?.put !== 'function') {
|
|
2630
|
+
throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the health token`);
|
|
2631
|
+
}
|
|
2632
|
+
const token = await this.ensureSharedSecret(org, healthTokenName(entry.key), recorded());
|
|
2633
|
+
this.handledSecrets.add(token);
|
|
2634
|
+
return token;
|
|
2635
|
+
},
|
|
2636
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
2637
|
+
runtime,
|
|
2638
|
+
sleep: runtime.sleep ?? defaultSleep,
|
|
2639
|
+
label: `${action.system}:${action.kind}:${entry.key}`,
|
|
2640
|
+
primaryBlueAppId,
|
|
2641
|
+
logDir: LOG_DIR,
|
|
2642
|
+
isRunning: isRunningOrStarting,
|
|
2643
|
+
};
|
|
2644
|
+
if (action.kind === 'bluegreen-lb') {
|
|
2645
|
+
await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
|
|
2646
|
+
}
|
|
2647
|
+
else {
|
|
2648
|
+
await applyBlueGreenServer(deps, subject, { uuid: serverUuid, primaryUuid });
|
|
2649
|
+
}
|
|
2650
|
+
return undefined;
|
|
2651
|
+
}
|
|
2504
2652
|
async coolifyApp(action, org, context) {
|
|
2505
2653
|
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
2506
2654
|
if (action.desired.enabled === false) {
|
|
@@ -2538,6 +2686,11 @@ export class FoundryStepExecutor {
|
|
|
2538
2686
|
}
|
|
2539
2687
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2540
2688
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2689
|
+
// A blue/green entry's app is its blue copy. An app that ran before blue/green keeps its own name and is
|
|
2690
|
+
// adopted as blue; an entry with no app yet gets `<base>-blue`. The name is recorded once decided.
|
|
2691
|
+
const blueNameKey = bgKey.blueName(entry.key);
|
|
2692
|
+
const blueNameRecorded = Boolean(context.recordedIds()[blueNameKey]);
|
|
2693
|
+
let appName = liveAppNameFor(org, entry, context.recordedIds());
|
|
2541
2694
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
2542
2695
|
// recorded app is gone.
|
|
2543
2696
|
let existing = null;
|
|
@@ -2546,17 +2699,35 @@ export class FoundryStepExecutor {
|
|
|
2546
2699
|
try {
|
|
2547
2700
|
live = await this.ports.coolify.getApplication(recordedAppId);
|
|
2548
2701
|
existing = { uuid: recordedAppId };
|
|
2702
|
+
if (entry.blueGreen &&
|
|
2703
|
+
!blueNameRecorded &&
|
|
2704
|
+
live.name === colorAppName(appNameFor(org, entry), 'blue')) {
|
|
2705
|
+
appName = live.name;
|
|
2706
|
+
}
|
|
2549
2707
|
}
|
|
2550
2708
|
catch (error) {
|
|
2551
2709
|
if (!isNotFoundError(error))
|
|
2552
2710
|
throw error;
|
|
2553
2711
|
}
|
|
2554
2712
|
}
|
|
2713
|
+
if (!existing && entry.blueGreen && !blueNameRecorded) {
|
|
2714
|
+
const own = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
|
|
2715
|
+
environmentIds: [environment.id],
|
|
2716
|
+
});
|
|
2717
|
+
if (own) {
|
|
2718
|
+
existing = own;
|
|
2719
|
+
appName = appNameFor(org, entry);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2555
2722
|
if (!existing) {
|
|
2556
|
-
existing = await this.ports.coolify.findApplicationByName(
|
|
2723
|
+
existing = await this.ports.coolify.findApplicationByName(appName, {
|
|
2557
2724
|
environmentIds: [environment.id],
|
|
2558
2725
|
});
|
|
2559
2726
|
}
|
|
2727
|
+
if (entry.blueGreen && !blueNameRecorded) {
|
|
2728
|
+
context.recordScopeId(blueNameKey, appName);
|
|
2729
|
+
await context.persistScopeIds();
|
|
2730
|
+
}
|
|
2560
2731
|
// No entry gets Coolify `domains`: Coolify would add a Traefik router for them. The company's
|
|
2561
2732
|
// tunnel routes an entry's names straight to its container (also `<slug>.oikos.host`, through a
|
|
2562
2733
|
// record in the platform's oikos.host zone), which every entry reaches as `<slug>-<key>` (its
|
|
@@ -2602,7 +2773,7 @@ export class FoundryStepExecutor {
|
|
|
2602
2773
|
git_repository: appRepo,
|
|
2603
2774
|
git_branch: entry.branch,
|
|
2604
2775
|
build_pack: entry.buildPack,
|
|
2605
|
-
name:
|
|
2776
|
+
name: appName,
|
|
2606
2777
|
autogenerate_domain: false,
|
|
2607
2778
|
// A static site is served by Coolify's own web server on port 80.
|
|
2608
2779
|
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
@@ -2655,7 +2826,7 @@ export class FoundryStepExecutor {
|
|
|
2655
2826
|
patch.domains = '';
|
|
2656
2827
|
}
|
|
2657
2828
|
if (!existing || patch.domains !== undefined) {
|
|
2658
|
-
patch.custom_internal_name =
|
|
2829
|
+
patch.custom_internal_name = appName;
|
|
2659
2830
|
}
|
|
2660
2831
|
// A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
|
|
2661
2832
|
// and uses them instead of the labels it generates from `domains`. Rule 4:
|
|
@@ -2709,8 +2880,8 @@ export class FoundryStepExecutor {
|
|
|
2709
2880
|
// `ensureLogVolume` whether this app was already running before this
|
|
2710
2881
|
// call -- see its own doc comment for why that decides whether to
|
|
2711
2882
|
// restart it.
|
|
2712
|
-
await this.ensureLogVolume(appId, org, Boolean(existing),
|
|
2713
|
-
await this.ensureEntryVolumes(appId, entry, Boolean(existing),
|
|
2883
|
+
await this.ensureLogVolume(appId, org, Boolean(existing), appName);
|
|
2884
|
+
await this.ensureEntryVolumes(appId, entry, Boolean(existing), appName);
|
|
2714
2885
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
2715
2886
|
// unlike the database push above: every app has a hostname, and an
|
|
2716
2887
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
@@ -2741,8 +2912,10 @@ export class FoundryStepExecutor {
|
|
|
2741
2912
|
// The public name answers from the container only once the tunnel step has written the
|
|
2742
2913
|
// direct route; before that (a first apply, or the one that moves it) the deployment is the check.
|
|
2743
2914
|
hostname &&
|
|
2915
|
+
// The public name goes through the balancer, which may answer from the other color.
|
|
2916
|
+
!entry.blueGreen &&
|
|
2744
2917
|
(context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') ===
|
|
2745
|
-
entryRoutesMarker(org)
|
|
2918
|
+
entryRoutesMarker(org, context.recordedIds())
|
|
2746
2919
|
? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
|
|
2747
2920
|
: { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
|
|
2748
2921
|
await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
|
|
@@ -6258,6 +6431,42 @@ export class FoundryStepExecutor {
|
|
|
6258
6431
|
}
|
|
6259
6432
|
return { resourceId: registered.id };
|
|
6260
6433
|
}
|
|
6434
|
+
/** The blue/green database route (`plan/remote-database.ts`). */
|
|
6435
|
+
async remoteDatabaseRoute(action, org, context) {
|
|
6436
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
6437
|
+
const desired = action.desired;
|
|
6438
|
+
// The tunnel's TCP rule names the database by the host the apps' own `DATABASE_URL` uses.
|
|
6439
|
+
const db = await this.ports.coolify.getDatabase(desired.database);
|
|
6440
|
+
if (db.internal_db_url)
|
|
6441
|
+
this.handledSecrets.add(db.internal_db_url);
|
|
6442
|
+
let databaseHost = '';
|
|
6443
|
+
try {
|
|
6444
|
+
databaseHost = new URL(db.internal_db_url ?? '').hostname;
|
|
6445
|
+
}
|
|
6446
|
+
catch {
|
|
6447
|
+
// Fixed text below: the URL holds the database password.
|
|
6448
|
+
}
|
|
6449
|
+
if (!databaseHost) {
|
|
6450
|
+
throw new Error(`cloudflare:db-route for ${org.slug}: the database reports no usable address`);
|
|
6451
|
+
}
|
|
6452
|
+
await applyRemoteDatabase({
|
|
6453
|
+
tunnels: this.tunnelPort(),
|
|
6454
|
+
access: this.accessPort(),
|
|
6455
|
+
dns: this.dnsPort(),
|
|
6456
|
+
zones: this.zonePort(),
|
|
6457
|
+
coolify: this.ports.coolify,
|
|
6458
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
6459
|
+
}, {
|
|
6460
|
+
org,
|
|
6461
|
+
desired,
|
|
6462
|
+
databaseHost,
|
|
6463
|
+
projectUuid,
|
|
6464
|
+
recorded: () => context.recordedIds(),
|
|
6465
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
6466
|
+
persist: () => context.persistScopeIds(),
|
|
6467
|
+
});
|
|
6468
|
+
return {};
|
|
6469
|
+
}
|
|
6261
6470
|
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
6262
6471
|
async deliverWithRetry(ref, value) {
|
|
6263
6472
|
for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
|
|
@@ -6337,17 +6546,19 @@ export class FoundryStepExecutor {
|
|
|
6337
6546
|
const loginHost = action.desired.loginHost ?? null;
|
|
6338
6547
|
// A hosting entry's names (and its wildcard) go straight to its container on the company network:
|
|
6339
6548
|
// the container carries `traefik.enable=false`, so the proxy has no route for it.
|
|
6340
|
-
const entryOrigins = new Map(entryRoutes(org).map((r) => [r.hostname, r.service]));
|
|
6549
|
+
const entryOrigins = new Map(entryRoutes(org, context.recordedIds()).map((r) => [r.hostname, r.service]));
|
|
6341
6550
|
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
6342
6551
|
hostname,
|
|
6343
6552
|
service: hostname === loginHost
|
|
6344
6553
|
? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
|
|
6345
6554
|
: (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
|
|
6346
6555
|
}));
|
|
6556
|
+
const httpIngress = [...ingress, { service: 'http_status:404' }];
|
|
6347
6557
|
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
6348
|
-
ingress:
|
|
6558
|
+
ingress: mergeTcpIngress(httpIngress, recordedTcpRoutes(context.recordedIds())),
|
|
6349
6559
|
});
|
|
6350
|
-
context.recordScopeId(
|
|
6560
|
+
context.recordScopeId(TUNNEL_HTTP_INGRESS_SCOPE_KEY, JSON.stringify(httpIngress));
|
|
6561
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org, context.recordedIds()));
|
|
6351
6562
|
// #235: a connector for this org's own tunnel, the same call every
|
|
6352
6563
|
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
6353
6564
|
// one connector per org tunnel, never a shared tunnel with appended
|
|
@@ -8114,8 +8325,32 @@ export class FoundryStepExecutor {
|
|
|
8114
8325
|
return;
|
|
8115
8326
|
}
|
|
8116
8327
|
case 'coolify:app':
|
|
8328
|
+
case 'coolify:bg-app':
|
|
8117
8329
|
await this.ports.coolify.deleteApplication(target.id);
|
|
8118
8330
|
return;
|
|
8331
|
+
case 'cloudflare:bg-access':
|
|
8332
|
+
await this.accessPort().deleteAccessApp(target.id);
|
|
8333
|
+
return;
|
|
8334
|
+
case 'cloudflare:bg-token':
|
|
8335
|
+
await this.accessPort().removeServiceToken(target.id);
|
|
8336
|
+
return;
|
|
8337
|
+
case 'cloudflare:bg-dns': {
|
|
8338
|
+
// `target.id` is the recorded route ({ hostname, service }); its name lives in the org's zone.
|
|
8339
|
+
const hostname = JSON.parse(target.id).hostname;
|
|
8340
|
+
const zone = org.domain ? await this.zonePort().findZoneByApex(org.domain) : undefined;
|
|
8341
|
+
if (typeof hostname !== 'string' || !zone)
|
|
8342
|
+
return;
|
|
8343
|
+
const dns = this.dnsPort();
|
|
8344
|
+
for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
|
|
8345
|
+
if (record.content.endsWith('.cfargotunnel.com')) {
|
|
8346
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
8347
|
+
}
|
|
8348
|
+
}
|
|
8349
|
+
return;
|
|
8350
|
+
}
|
|
8351
|
+
case 'coolify:bg-database':
|
|
8352
|
+
await this.ports.coolify.deleteDatabase(target.id);
|
|
8353
|
+
return;
|
|
8119
8354
|
case 'coolify:database':
|
|
8120
8355
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
8121
8356
|
return;
|
|
@@ -2,18 +2,21 @@ import type { DestroyProgress, PlanApply } from '../apply.js';
|
|
|
2
2
|
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
3
3
|
import type { Plan, PlanStep } from '../plan.js';
|
|
4
4
|
import type { DeclaredPlatform } from '../platform.js';
|
|
5
|
+
import type { ReleaseStore } from '../release/state.js';
|
|
5
6
|
import type { JobRunner } from '../runner/types.js';
|
|
6
7
|
import type { AppliedState } from '../state.js';
|
|
7
8
|
import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore } from '../store.js';
|
|
8
9
|
import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js';
|
|
9
10
|
import { type AgoraHostPort } from './agora.js';
|
|
10
11
|
import { type MoveGatePorts } from './backup-gate.js';
|
|
12
|
+
import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
|
|
11
13
|
import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
|
|
12
14
|
import { type StepAction } from './diff.js';
|
|
13
15
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
14
16
|
import { type LoginHostDiscoveryPort } from './login-host.js';
|
|
15
17
|
import { type EdgePortName, type HeldKeyResolver, type PlatformOwnerInput } from './platform-org.js';
|
|
16
18
|
import type { CallsSfuConnectionPort } from './platform-sfu.js';
|
|
19
|
+
import { type ReleaseContext, type ReleaseHost } from './release-host.js';
|
|
17
20
|
/** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
|
|
18
21
|
export interface AuditActor {
|
|
19
22
|
readonly id: string;
|
|
@@ -205,6 +208,17 @@ export interface StepExecutor {
|
|
|
205
208
|
}>;
|
|
206
209
|
/** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
|
|
207
210
|
ownMailKeyMintable?(): boolean;
|
|
211
|
+
/** Every Coolify server with whether it is usable and reachable; blue/green plans copies on each. Optional: an executor without it plans no blue/green. */
|
|
212
|
+
blueGreenServers?(): Promise<Array<{
|
|
213
|
+
uuid: string;
|
|
214
|
+
usable: boolean;
|
|
215
|
+
reachable: boolean;
|
|
216
|
+
}>>;
|
|
217
|
+
/**
|
|
218
|
+
* The release runner's outside calls for one blue/green entry (`plan/release-host.ts`); `null` while its blue
|
|
219
|
+
* copy is not recorded. Optional: an executor without it runs no release.
|
|
220
|
+
*/
|
|
221
|
+
releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
|
|
208
222
|
/** Whether the executor's held keys can store the sign-in database's CA and certificate (#746). Absent: true. */
|
|
209
223
|
ownSigninDbKeysPuttable?(): boolean;
|
|
210
224
|
/** Whether the host supplies the backup bucket the sign-in database dumps into (#746). Absent: true. */
|
|
@@ -325,6 +339,16 @@ export interface PlatformStepExecutor {
|
|
|
325
339
|
* one), or `null` when nothing changed.
|
|
326
340
|
*/
|
|
327
341
|
refreshFilesShare?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<Record<string, string> | null>;
|
|
342
|
+
/**
|
|
343
|
+
* The Coolify servers and the reason platform-archon's blue/green cannot run (the executor cannot hold the
|
|
344
|
+
* health token), for the plan. Optional: an executor without it plans no blue/green for archon.
|
|
345
|
+
*/
|
|
346
|
+
archonBlueGreen?(): Promise<{
|
|
347
|
+
servers?: BlueGreenServer[];
|
|
348
|
+
blocked?: string;
|
|
349
|
+
}>;
|
|
350
|
+
/** The release runner's outside calls for platform-archon (`plan/release-host.ts`); `null` while its app is not recorded. */
|
|
351
|
+
releaseHost?(platform: DeclaredPlatform, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
|
|
328
352
|
execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
|
|
329
353
|
resourceId?: string;
|
|
330
354
|
} | undefined>;
|
|
@@ -510,6 +534,11 @@ export interface FoundryPlanApplyOptions {
|
|
|
510
534
|
step(action: StepAction): boolean;
|
|
511
535
|
blocked(key: string): boolean;
|
|
512
536
|
};
|
|
537
|
+
/**
|
|
538
|
+
* The release requests (Archon's promote, roll back, clear held and decide-at-cap clicks), consumed once each by
|
|
539
|
+
* the release runner. Without it no release runs, in an apply or in {@link FoundryPlanApply.runReleaseTick}.
|
|
540
|
+
*/
|
|
541
|
+
releaseStore?: ReleaseStore;
|
|
513
542
|
/** Overridable for tests; defaults to `() => new Date()`. */
|
|
514
543
|
now?: () => Date;
|
|
515
544
|
/** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
|
|
@@ -605,6 +634,7 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
605
634
|
private readonly backupGate;
|
|
606
635
|
private readonly platformDeps;
|
|
607
636
|
private readonly runner;
|
|
637
|
+
private readonly releaseStore;
|
|
608
638
|
constructor(options: FoundryPlanApplyOptions);
|
|
609
639
|
plan(id: OrgId): Promise<Plan>;
|
|
610
640
|
/**
|
|
@@ -741,6 +771,27 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
741
771
|
private refusePlatform;
|
|
742
772
|
private refuseDestroy;
|
|
743
773
|
private runSteps;
|
|
774
|
+
/**
|
|
775
|
+
* ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
|
|
776
|
+
* (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
|
|
777
|
+
* before this returns.
|
|
778
|
+
*/
|
|
779
|
+
private runOrgReleases;
|
|
780
|
+
/** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
|
|
781
|
+
private runPlatformReleases;
|
|
782
|
+
private runPlatformReleaseTick;
|
|
783
|
+
/**
|
|
784
|
+
* The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
|
|
785
|
+
* archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
|
|
786
|
+
* no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
|
|
787
|
+
* apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
|
|
788
|
+
* A refusal is audited; a run that does nothing writes no audit row.
|
|
789
|
+
*/
|
|
790
|
+
runReleaseTick(): Promise<Array<{
|
|
791
|
+
target: string;
|
|
792
|
+
error?: string;
|
|
793
|
+
}>>;
|
|
794
|
+
private runOrgReleaseTick;
|
|
744
795
|
/** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
|
|
745
796
|
private readSfuPortsOpen;
|
|
746
797
|
private computeFreshPlan;
|