@wtfalch/foundry 0.41.2 → 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 +7 -3
- package/dist/index.js +4 -2
- package/dist/migrations/0054_org_signin_in_app.sql +6 -0
- 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 +30 -0
- package/dist/org.js +52 -1
- package/dist/plan/agora.d.ts +4 -1
- package/dist/plan/agora.js +16 -2
- 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 +5 -2
- package/dist/plan/boule.js +14 -2
- package/dist/plan/cloudflared-connector.d.ts +26 -0
- package/dist/plan/cloudflared-connector.js +37 -0
- package/dist/plan/coolify-previews.d.ts +10 -0
- package/dist/plan/coolify-previews.js +14 -0
- package/dist/plan/diff.d.ts +6 -0
- package/dist/plan/diff.js +123 -11
- package/dist/plan/edge-ratelimits.d.ts +5 -0
- package/dist/plan/edge-ratelimits.js +16 -6
- package/dist/plan/executor.d.ts +79 -0
- package/dist/plan/executor.js +527 -31
- package/dist/plan/login-host.d.ts +23 -2
- package/dist/plan/login-host.js +30 -5
- package/dist/plan/own-diff.js +6 -0
- 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 +28 -1
- package/dist/plan/platform-org.d.ts +6 -0
- package/dist/plan/platform-org.js +5 -0
- package/dist/plan/platform-providers.d.ts +31 -2
- package/dist/plan/platform-providers.js +345 -13
- package/dist/plan/providers-readers.js +6 -2
- 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/plan/sign-in-archon.d.ts +46 -0
- package/dist/plan/sign-in-archon.js +71 -0
- package/dist/plan/sign-in-bindings.d.ts +16 -0
- package/dist/plan/sign-in-bindings.js +44 -0
- package/dist/plan/sign-in.d.ts +135 -0
- package/dist/plan/sign-in.js +172 -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 +62 -3
- package/dist/store/schema.d.ts +171 -0
- package/dist/store/schema.js +14 -0
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- package/versions.json +2 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import { type InAppApp, type SIGN_IN_BASE_PATH } from './sign-in.js';
|
|
2
3
|
/**
|
|
3
4
|
* #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
4
5
|
* need to agree on for an org's own company login host -- the hostname and
|
|
@@ -99,7 +100,17 @@ export interface LoginHostBinding {
|
|
|
99
100
|
host: string;
|
|
100
101
|
organizationId: string;
|
|
101
102
|
clientIds: string[];
|
|
103
|
+
/** In-app sign-in: which app each client id is, so the login draws that app's room. Absent: the company's own room. */
|
|
104
|
+
apps?: Record<string, 'agora' | 'boule' | 'archon'>;
|
|
105
|
+
/** In-app sign-in: the login serves this host under `/sign-in`. Absent: the root. */
|
|
106
|
+
basePath?: typeof SIGN_IN_BASE_PATH;
|
|
102
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The app hosts that sign in on their own address when the company has `signIn.inApp`: Agora's and
|
|
110
|
+
* Boule's, each only once its client id is recorded. Empty with the switch off. Archon (owner company
|
|
111
|
+
* only) is not here: the platform's Archon step owns its host (`sign-in-archon.ts`).
|
|
112
|
+
*/
|
|
113
|
+
export declare function inAppApps(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): InAppApp[];
|
|
103
114
|
/**
|
|
104
115
|
* Host-supplied write access to zitadel-login's own `LOGIN_HOST_BINDINGS`
|
|
105
116
|
* env (`auth/docs/company-login-hosts.md`'s own "Set `LOGIN_HOST_BINDINGS`
|
|
@@ -121,6 +132,12 @@ export interface LoginHostBindingsPort {
|
|
|
121
132
|
* package.
|
|
122
133
|
*/
|
|
123
134
|
deleteBinding(host: string): Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* A port that queues its upserts writes them here, once: one redeploy of the login however many hosts
|
|
137
|
+
* the step bound. A port without it writes on each call. `deleteBinding` writes at once on either.
|
|
138
|
+
* The login-host step calls it after all its upserts and before any app is repointed.
|
|
139
|
+
*/
|
|
140
|
+
flush?(): Promise<void>;
|
|
124
141
|
}
|
|
125
142
|
/**
|
|
126
143
|
* Scope id holding what the binding was last written with
|
|
@@ -137,8 +154,12 @@ export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
|
|
|
137
154
|
* is no longer declared is not here, so it stays bound as before.
|
|
138
155
|
*/
|
|
139
156
|
export declare function customersEntryKeys(org: DeclaredOrg): Set<string>;
|
|
140
|
-
/**
|
|
141
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule),
|
|
159
|
+
* deduplicated and sorted, bar `except`: the login refuses a client id held by two bindings, so the ids of the
|
|
160
|
+
* app hosts that have their own binding (in-app sign-in) are not in the company's.
|
|
161
|
+
*/
|
|
162
|
+
export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, except?: readonly string[]): string[];
|
|
142
163
|
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
143
164
|
export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
|
|
144
165
|
/**
|
package/dist/plan/login-host.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ownsServices } from '../org.js';
|
|
2
|
-
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, } from './agora.js';
|
|
3
|
-
import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
|
|
2
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, } from './agora.js';
|
|
3
|
+
import { BOULE_CLIENT_ID_SCOPE_KEY, bouleHostnameFor } from './boule.js';
|
|
4
|
+
import { SIGN_IN_IN_APP_SCOPE_KEY, inAppSignIn, inAppStatePairs, } from './sign-in.js';
|
|
4
5
|
/**
|
|
5
6
|
* #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
6
7
|
* need to agree on for an org's own company login host -- the hostname and
|
|
@@ -120,7 +121,10 @@ export function loginHostServesAgora(host, org, recorded) {
|
|
|
120
121
|
if (!bound?.startsWith(`${host} `))
|
|
121
122
|
return false;
|
|
122
123
|
const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
|
|
124
|
+
// Agora's client moves to its own host's binding once that host signs in on the app's address.
|
|
125
|
+
const movedToAppHost = inAppStatePairs(recorded[SIGN_IN_IN_APP_SCOPE_KEY]).some((pair) => pair.clientId === clientId);
|
|
123
126
|
return (!clientId ||
|
|
127
|
+
movedToAppHost ||
|
|
124
128
|
bound
|
|
125
129
|
.slice(host.length + 1)
|
|
126
130
|
.split(',')
|
|
@@ -132,6 +136,23 @@ export function loginHostServesAgora(host, org, recorded) {
|
|
|
132
136
|
* instance, so `destroy` must not remove it.
|
|
133
137
|
*/
|
|
134
138
|
export const LOGIN_HOST_SHARED_SCOPE_KEY = 'zitadel_login_host_shared';
|
|
139
|
+
/**
|
|
140
|
+
* The app hosts that sign in on their own address when the company has `signIn.inApp`: Agora's and
|
|
141
|
+
* Boule's, each only once its client id is recorded. Empty with the switch off. Archon (owner company
|
|
142
|
+
* only) is not here: the platform's Archon step owns its host (`sign-in-archon.ts`).
|
|
143
|
+
*/
|
|
144
|
+
export function inAppApps(org, recorded) {
|
|
145
|
+
if (!inAppSignIn(org))
|
|
146
|
+
return [];
|
|
147
|
+
const apps = [];
|
|
148
|
+
const agora = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
|
|
149
|
+
if (agora)
|
|
150
|
+
apps.push({ app: 'agora', host: agoraHostnameFor(org), clientId: agora });
|
|
151
|
+
const boule = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
|
|
152
|
+
if (boule)
|
|
153
|
+
apps.push({ app: 'boule', host: bouleHostnameFor(org), clientId: boule });
|
|
154
|
+
return apps.sort((a, b) => (a.host < b.host ? -1 : 1));
|
|
155
|
+
}
|
|
135
156
|
/**
|
|
136
157
|
* Scope id holding what the binding was last written with
|
|
137
158
|
* ({@link loginBindingState}). Client ids are not secrets. The planner
|
|
@@ -149,8 +170,12 @@ export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
|
|
|
149
170
|
export function customersEntryKeys(org) {
|
|
150
171
|
return new Set(org.hosting.entries.filter((e) => e.uses?.auth?.users === 'customers').map((e) => e.key));
|
|
151
172
|
}
|
|
152
|
-
/**
|
|
153
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule),
|
|
175
|
+
* deduplicated and sorted, bar `except`: the login refuses a client id held by two bindings, so the ids of the
|
|
176
|
+
* app hosts that have their own binding (in-app sign-in) are not in the company's.
|
|
177
|
+
*/
|
|
178
|
+
export function loginHostClientIds(org, recorded, except = []) {
|
|
154
179
|
const customers = customersEntryKeys(org);
|
|
155
180
|
const ids = [
|
|
156
181
|
recorded[APP_CLIENT_ID_SCOPE_KEY],
|
|
@@ -162,7 +187,7 @@ export function loginHostClientIds(org, recorded) {
|
|
|
162
187
|
!customers.has(key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)))
|
|
163
188
|
.map(([, id]) => id),
|
|
164
189
|
].filter((id) => Boolean(id));
|
|
165
|
-
return [...new Set(ids)].sort();
|
|
190
|
+
return [...new Set(ids)].filter((id) => !except.includes(id)).sort();
|
|
166
191
|
}
|
|
167
192
|
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
168
193
|
export function loginBindingState(host, clientIds) {
|
package/dist/plan/own-diff.js
CHANGED
|
@@ -13,6 +13,7 @@ import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_NODE_IP_SCOPE_KEY,
|
|
|
13
13
|
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
|
|
14
14
|
import { OWN_FILES_RUNNER_SCOPE_KEY } from './platform-org.js';
|
|
15
15
|
import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
|
|
16
|
+
import { SIGN_IN_LOGIN_APP_SCOPE_KEY, inAppSignIn } from './sign-in.js';
|
|
16
17
|
import { signinDbBackupMissing } from './signin-db-backup.js';
|
|
17
18
|
import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY, signinDbCertDue } from './signin-db-tls.js';
|
|
18
19
|
import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db.js';
|
|
@@ -162,6 +163,11 @@ export function planOwnAuth(options) {
|
|
|
162
163
|
recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY] &&
|
|
163
164
|
recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
|
|
164
165
|
(Boolean(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]) || !brokerHoldsAnEntry) &&
|
|
166
|
+
// In-app sign-in: the second login app is recorded once it is deployed and holds the token, which
|
|
167
|
+
// needs the login client; until the broker holds an entry that cannot be, so the step waits.
|
|
168
|
+
(!inAppSignIn(org) ||
|
|
169
|
+
Boolean(recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY]) ||
|
|
170
|
+
!brokerHoldsAnEntry) &&
|
|
165
171
|
!signinDbCertDue(recordedIds, options.now ?? Date.now()) &&
|
|
166
172
|
!signinDbBackupMissing(recordedIds) &&
|
|
167
173
|
// ZITADEL was started on the TLS database and never seen healthy there: the step starts it again.
|
|
@@ -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;
|
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';
|
|
@@ -11,6 +12,7 @@ import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
|
|
|
11
12
|
import { MAIL_DATABASE_SCOPE_KEY, mailStoreIsLegacy, mailStoreSwitchChange, mailStoreSwitchDescription, } from './mail-store.js';
|
|
12
13
|
import { planOikosIngress } from './oikos-ingress.js';
|
|
13
14
|
import { callsSfuAppliedFingerprint } from './platform-sfu.js';
|
|
15
|
+
import { archonInAppHost } from './sign-in-archon.js';
|
|
14
16
|
import { signInDomainBlockedText } from './sign-in-org.js';
|
|
15
17
|
import { withStepSystem } from './step-system.js';
|
|
16
18
|
/**
|
|
@@ -1267,7 +1269,7 @@ export function uptimeRecorded(recordedIds) {
|
|
|
1267
1269
|
* unchanged).
|
|
1268
1270
|
*/
|
|
1269
1271
|
export function planPlatform(platform, live, options = {}) {
|
|
1270
|
-
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;
|
|
1271
1273
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
1272
1274
|
const claimed = new Set();
|
|
1273
1275
|
const plannedSteps = [];
|
|
@@ -1385,6 +1387,27 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
1385
1387
|
const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld, blocked.uptimeChat);
|
|
1386
1388
|
if (uptime.step)
|
|
1387
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
|
+
}
|
|
1388
1411
|
if (archon.step && blocked.archonRunsInsideApp)
|
|
1389
1412
|
plannedSteps.push(archon.step);
|
|
1390
1413
|
// After Archon's own step: a repair that fails must not hold up the work before it.
|
|
@@ -1486,6 +1509,7 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
1486
1509
|
unmanaged.push({ description: archonOrg.blocked, key: 'archon-org' });
|
|
1487
1510
|
if (archon.blocked)
|
|
1488
1511
|
unmanaged.push({ description: archon.blocked, key: 'archon' });
|
|
1512
|
+
unmanaged.push(...archonBg.unmanaged);
|
|
1489
1513
|
// #398: the old integrations key stays pinned until Archon holds the new
|
|
1490
1514
|
// id; name that when Archon is what is stopping it.
|
|
1491
1515
|
if (archon.blocked &&
|
|
@@ -3055,6 +3079,9 @@ function planArchon(platform, recordedIds, executorBlocked, ownerSignIn, ownerFi
|
|
|
3055
3079
|
recordedIds[PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY] === ownerSignIn.organisationId &&
|
|
3056
3080
|
(recordedIds[PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY] ?? '') ===
|
|
3057
3081
|
archonFirstOwnerLink(recordedIds, ownerSignIn) &&
|
|
3082
|
+
// In-app sign-in: Archon's own address is the sign-in address only once the owner company has the
|
|
3083
|
+
// switch on and its in-app login is applied, and goes back to the company's login host when it does not.
|
|
3084
|
+
archonInAppHost(recordedIds) === (ownerSignIn.signInInApp ? platform.archon.hostname : '') &&
|
|
3058
3085
|
recordedIds[PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY] === archonOrgId &&
|
|
3059
3086
|
!recordedIds[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY] &&
|
|
3060
3087
|
Boolean(recordedIds[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY]) &&
|