@wtfalch/foundry 0.70.1 → 0.71.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/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/plan/executor.d.ts +16 -0
- package/dist/plan/executor.js +36 -2
- package/dist/plan/plan-apply.d.ts +21 -0
- package/dist/plan/plan-apply.js +70 -0
- package/dist/platform.d.ts +3 -0
- package/dist/release/fanout.d.ts +77 -0
- package/dist/release/fanout.js +177 -0
- package/dist/release/run.js +2 -1
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +3 -0
- package/dist/release/types.d.ts +9 -1
- package/dist/store/schema.d.ts +4 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export { createPostgresAuditLog } from './store/audit.js';
|
|
|
25
25
|
export type { Color, ReleaseAction, ReleaseEvent, ReleaseEventKind, ReleaseSeries, ReleaseSeriesColor, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
|
|
26
26
|
export type { ReleaseStore } from './release/state.js';
|
|
27
27
|
export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
|
|
28
|
+
export type { BuiltinFanOut, FanOutApp, FanOutOrgState } from './release/fanout.js';
|
|
29
|
+
export { readBuiltinFanOut } from './release/fanout.js';
|
|
28
30
|
export type { ReleaseSeriesHost, ReleaseSeriesQuery } from './release/series.js';
|
|
29
31
|
export { readReleaseSeries } from './release/series.js';
|
|
30
32
|
export type { EntrySeriesDeps } from './plan/release-series-reader.js';
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { StaleLeaseError } from './store.js';
|
|
|
15
15
|
export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
16
16
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
17
17
|
export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
|
|
18
|
+
export { readBuiltinFanOut } from './release/fanout.js';
|
|
18
19
|
export { readReleaseSeries } from './release/series.js';
|
|
19
20
|
export { readEntryReleaseSeries } from './plan/release-series-reader.js';
|
|
20
21
|
export { checkMigrationCompatibility } from './release/migration-compatibility.js';
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -2489,6 +2489,22 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2489
2489
|
}>;
|
|
2490
2490
|
down: string[];
|
|
2491
2491
|
}>;
|
|
2492
|
+
/**
|
|
2493
|
+
* The built-in fan-out's in-place deploy (`release/fanout.ts`): pins the org's Boule or Agora to `sha`, deploys
|
|
2494
|
+
* it and waits at most STAGE_DEPLOY_ATTEMPTS polls (10 minutes) for Coolify to finish. The reasons are fixed
|
|
2495
|
+
* texts. Never touches a copy that runs blue/green: that one has colours and is released by its own runner.
|
|
2496
|
+
*/
|
|
2497
|
+
deployBuiltinAt(input: {
|
|
2498
|
+
app: 'boule' | 'agora';
|
|
2499
|
+
org: DeclaredOrg;
|
|
2500
|
+
recordedIds: Readonly<Record<string, string>>;
|
|
2501
|
+
sha: string;
|
|
2502
|
+
}): Promise<{
|
|
2503
|
+
ok: true;
|
|
2504
|
+
} | {
|
|
2505
|
+
ok: false;
|
|
2506
|
+
reason: string;
|
|
2507
|
+
}>;
|
|
2492
2508
|
/**
|
|
2493
2509
|
* The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
|
|
2494
2510
|
* blue copy is not recorded. The health token is only read here, never minted.
|
package/dist/plan/executor.js
CHANGED
|
@@ -30,7 +30,7 @@ import { BACKUP_POSTGRES_DATABASES_SCOPE_KEY, BACKUP_SCOPE_PREFIX, COMPANY_BACKU
|
|
|
30
30
|
import { LOGIN_COOKIE_DOMAIN_SCOPE_KEY } from './cookie-domain.js';
|
|
31
31
|
import { previewsOn } from './coolify-previews.js';
|
|
32
32
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
33
|
-
import { waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
33
|
+
import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
34
34
|
import { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_DOCKERFILE, DESKTOP_AUTH_HOME, DESKTOP_AUTH_ORIGIN_SCOPE_KEY, DESKTOP_AUTH_READY_PATH, DESKTOP_AUTH_READY_SCOPE_KEY, DESKTOP_AUTH_REDIRECT_SCOPE_KEY, DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthCallbackUri, desktopAuthEnv, desktopAuthInputs, desktopAuthRedirectUnsupported, desktopAuthRevision, } from './desktop-auth.js';
|
|
35
35
|
import { desktopCallbackUri, signInCallbackAppliedValue, signInRedirectUris, unionRedirectUris, zitadelAppDesktopCallbackAppliedScopeKey, } from './desktop-callback.js';
|
|
36
36
|
import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, placedOnPlatform, } from './destinations.js';
|
|
@@ -77,7 +77,7 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
|
|
|
77
77
|
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
78
78
|
import { companyAppPostLogoutUris, postLogoutAppliedValue, postLogoutUrisFor, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
|
|
79
79
|
import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsAppsZoneName, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
80
|
-
import { createReleaseHost } from './release-host.js';
|
|
80
|
+
import { STAGE_DEPLOY_ATTEMPTS, STAGE_DEPLOY_INTERVAL_MS, createReleaseHost, } from './release-host.js';
|
|
81
81
|
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
82
82
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
83
83
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
@@ -3183,6 +3183,40 @@ export class FoundryStepExecutor {
|
|
|
3183
3183
|
down,
|
|
3184
3184
|
};
|
|
3185
3185
|
}
|
|
3186
|
+
/**
|
|
3187
|
+
* The built-in fan-out's in-place deploy (`release/fanout.ts`): pins the org's Boule or Agora to `sha`, deploys
|
|
3188
|
+
* it and waits at most STAGE_DEPLOY_ATTEMPTS polls (10 minutes) for Coolify to finish. The reasons are fixed
|
|
3189
|
+
* texts. Never touches a copy that runs blue/green: that one has colours and is released by its own runner.
|
|
3190
|
+
*/
|
|
3191
|
+
async deployBuiltinAt(input) {
|
|
3192
|
+
const { app, org, sha } = input;
|
|
3193
|
+
if (!/^[0-9a-f]{40}$/.test(sha))
|
|
3194
|
+
return { ok: false, reason: 'not a built commit' };
|
|
3195
|
+
if (org[app]?.blueGreen !== undefined)
|
|
3196
|
+
return { ok: false, reason: 'copy runs blue/green' };
|
|
3197
|
+
const appId = input.recordedIds[`coolify:${app}:${org.id}`];
|
|
3198
|
+
if (!appId)
|
|
3199
|
+
return { ok: false, reason: 'app not recorded' };
|
|
3200
|
+
try {
|
|
3201
|
+
await this.ports.coolify.updateApplication(appId, { git_commit_sha: sha });
|
|
3202
|
+
}
|
|
3203
|
+
catch (error) {
|
|
3204
|
+
console.error(`fanout ${app}: selecting the commit failed (${error instanceof Error ? error.name : 'Error'})`);
|
|
3205
|
+
return { ok: false, reason: 'select commit failed' };
|
|
3206
|
+
}
|
|
3207
|
+
try {
|
|
3208
|
+
await waitForDeploymentFinished(this.ports.coolify, appId, {
|
|
3209
|
+
...(this.ports.runtime ?? {}),
|
|
3210
|
+
deployAttempts: STAGE_DEPLOY_ATTEMPTS,
|
|
3211
|
+
deployIntervalMs: STAGE_DEPLOY_INTERVAL_MS,
|
|
3212
|
+
}, `coolify:fanout:${app}`, 'the copy stays as it was');
|
|
3213
|
+
}
|
|
3214
|
+
catch (error) {
|
|
3215
|
+
console.error(`fanout ${app}: the deployment did not finish (${error instanceof Error ? error.name : 'Error'})`);
|
|
3216
|
+
return { ok: false, reason: 'deployment did not finish' };
|
|
3217
|
+
}
|
|
3218
|
+
return { ok: true };
|
|
3219
|
+
}
|
|
3186
3220
|
/**
|
|
3187
3221
|
* The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
|
|
3188
3222
|
* blue copy is not recorded. The health token is only read here, never minted.
|
|
@@ -234,6 +234,21 @@ export interface StepExecutor {
|
|
|
234
234
|
* copy is not recorded. Optional: an executor without it runs no release.
|
|
235
235
|
*/
|
|
236
236
|
releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
|
|
237
|
+
/**
|
|
238
|
+
* The built-in fan-out's in-place deploy of one org's Boule or Agora at `sha` (`release/fanout.ts`). Optional:
|
|
239
|
+
* an executor without it runs no fan-out.
|
|
240
|
+
*/
|
|
241
|
+
deployBuiltinAt?(input: {
|
|
242
|
+
app: 'boule' | 'agora';
|
|
243
|
+
org: DeclaredOrg;
|
|
244
|
+
recordedIds: Readonly<Record<string, string>>;
|
|
245
|
+
sha: string;
|
|
246
|
+
}): Promise<{
|
|
247
|
+
ok: true;
|
|
248
|
+
} | {
|
|
249
|
+
ok: false;
|
|
250
|
+
reason: string;
|
|
251
|
+
}>;
|
|
237
252
|
/**
|
|
238
253
|
* One release tick for one subject: the recorded stage gate's list follows the people holding `releases:preview`,
|
|
239
254
|
* owners staying. Never throws. Optional: an executor without it leaves every gate as it is.
|
|
@@ -935,6 +950,12 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
935
950
|
target: string;
|
|
936
951
|
error?: string;
|
|
937
952
|
}>>;
|
|
953
|
+
/**
|
|
954
|
+
* One fan-out pass for the owner org's built-in `app` (`release/fanout.ts`): the other orgs' copies are put on
|
|
955
|
+
* the commit the owner's copy runs, one after the other, in place, stopping at the first failure. Each state
|
|
956
|
+
* write takes the owner's lease on its own; no lease is held while a deploy waits.
|
|
957
|
+
*/
|
|
958
|
+
private runBuiltinFanOut;
|
|
938
959
|
/**
|
|
939
960
|
* Phase 2 for one org: takes the copy lock of each stage copy request under the org's lease, then runs the
|
|
940
961
|
* copies after the lease is released. While a copy runs, every other tick and apply for the org goes on, a
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -4,6 +4,7 @@ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
|
4
4
|
import { sfuSlotPorts } from '../calls-sfu.js';
|
|
5
5
|
import { ORG_SERVICE_SCHEMAS, heldEnvRef, ownsServices, validateHosting } from '../org.js';
|
|
6
6
|
import { PLATFORM_SERVICE_KEYS } from '../platform.js';
|
|
7
|
+
import { runBuiltinFanOut } from '../release/fanout.js';
|
|
7
8
|
import { recordedFrom, releaseKeys } from '../release/state.js';
|
|
8
9
|
import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
|
|
9
10
|
import { StaleLeaseError, } from '../store.js';
|
|
@@ -1338,6 +1339,16 @@ export class FoundryPlanApply {
|
|
|
1338
1339
|
settle('platform', e);
|
|
1339
1340
|
}
|
|
1340
1341
|
}
|
|
1342
|
+
// Between the phases: once the owner's Boule or Agora is fully live, copy it to every other org. Its errors
|
|
1343
|
+
// are reported by app, never as an org.
|
|
1344
|
+
for (const app of ['boule', 'agora']) {
|
|
1345
|
+
try {
|
|
1346
|
+
await this.runBuiltinFanOut(app);
|
|
1347
|
+
}
|
|
1348
|
+
catch (e) {
|
|
1349
|
+
settle(`fanout:${app}`, e);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1341
1352
|
// Phase 2: the copies, one subject at a time. A copy takes its lock under the org's lease and runs without it.
|
|
1342
1353
|
for (const { target, run } of copying) {
|
|
1343
1354
|
try {
|
|
@@ -1350,6 +1361,65 @@ export class FoundryPlanApply {
|
|
|
1350
1361
|
}
|
|
1351
1362
|
return results;
|
|
1352
1363
|
}
|
|
1364
|
+
/**
|
|
1365
|
+
* One fan-out pass for the owner org's built-in `app` (`release/fanout.ts`): the other orgs' copies are put on
|
|
1366
|
+
* the commit the owner's copy runs, one after the other, in place, stopping at the first failure. Each state
|
|
1367
|
+
* write takes the owner's lease on its own; no lease is held while a deploy waits.
|
|
1368
|
+
*/
|
|
1369
|
+
async runBuiltinFanOut(app) {
|
|
1370
|
+
const store = this.releaseStore;
|
|
1371
|
+
const platform = await this.platformDeps?.store.get();
|
|
1372
|
+
if (!store || !platform || !this.executor.deployBuiltinAt)
|
|
1373
|
+
return;
|
|
1374
|
+
const ownerOrgId = platform.ownerOrgId;
|
|
1375
|
+
const owner = await this.store.get(ownerOrgId);
|
|
1376
|
+
if (!owner)
|
|
1377
|
+
return;
|
|
1378
|
+
const empty = { orgId: ownerOrgId, steps: [], unmanaged: [] };
|
|
1379
|
+
if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
|
|
1380
|
+
await this.refusePlatform(empty, ownerOrgId, 'not authorized to run the release tick');
|
|
1381
|
+
throw new NotAuthorizedError(this.actor, ownerOrgId);
|
|
1382
|
+
}
|
|
1383
|
+
const deploy = this.executor.deployBuiltinAt.bind(this.executor);
|
|
1384
|
+
await runBuiltinFanOut({
|
|
1385
|
+
ownerOrgId,
|
|
1386
|
+
app,
|
|
1387
|
+
ownerDeclares: owner[app]?.blueGreen !== undefined,
|
|
1388
|
+
store,
|
|
1389
|
+
readIds: async () => ({
|
|
1390
|
+
...((await this.store.getAppliedState(ownerOrgId))?.serviceIds ?? {}),
|
|
1391
|
+
}),
|
|
1392
|
+
write: (writes) => this.withMutationLease(companyScope(ownerOrgId), (reason) => this.refuse(empty, reason), async (fence) => {
|
|
1393
|
+
const previous = await this.store.getAppliedState(ownerOrgId);
|
|
1394
|
+
if (!previous)
|
|
1395
|
+
throw new Error('the owner org has no applied state');
|
|
1396
|
+
const serviceIds = { ...previous.serviceIds };
|
|
1397
|
+
for (const [key, value] of Object.entries(writes)) {
|
|
1398
|
+
if (value === null)
|
|
1399
|
+
delete serviceIds[key];
|
|
1400
|
+
else
|
|
1401
|
+
serviceIds[key] = value;
|
|
1402
|
+
}
|
|
1403
|
+
await this.store.putAppliedState({ ...previous, serviceIds }, fence);
|
|
1404
|
+
}),
|
|
1405
|
+
targets: async () => {
|
|
1406
|
+
const others = (await this.store.list()).filter((o) => o.id !== ownerOrgId && o[app] !== undefined);
|
|
1407
|
+
return others.map((o) => ({ orgId: o.id, slug: o.slug }));
|
|
1408
|
+
},
|
|
1409
|
+
// Re-reads the org fresh right before its own deploy, never from a list taken when the pass
|
|
1410
|
+
// started: a deploy can wait many minutes with no lease held on the target org (CLAUDE.md,
|
|
1411
|
+
// "Built-in fan-out"), so an ordinary `apply()` to a later-in-order org can turn its own
|
|
1412
|
+
// blue/green on in that window, and `deployBuiltinAt`'s guard must see that, not a stale copy.
|
|
1413
|
+
deploy: async (target, sha) => {
|
|
1414
|
+
const org = await this.store.get(target.orgId);
|
|
1415
|
+
if (!org || org[app] === undefined)
|
|
1416
|
+
return { ok: false, reason: 'org not found' };
|
|
1417
|
+
const recordedIds = (await this.store.getAppliedState(org.id))?.serviceIds ?? {};
|
|
1418
|
+
return deploy({ app, org, recordedIds, sha });
|
|
1419
|
+
},
|
|
1420
|
+
now: this.now,
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1353
1423
|
/**
|
|
1354
1424
|
* Phase 2 for one org: takes the copy lock of each stage copy request under the org's lease, then runs the
|
|
1355
1425
|
* copies after the lease is released. While a copy runs, every other tick and apply for the org goes on, a
|
package/dist/platform.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export interface PlatformArchonService extends PlatformService {
|
|
|
20
20
|
blueGreen?: {
|
|
21
21
|
steps?: number[];
|
|
22
22
|
stepHoldMinutes?: number;
|
|
23
|
+
advance?: 'time' | 'signal' | 'approval';
|
|
24
|
+
/** Archon's switch only: `{ steps: [100], staffFirst: true }` deploys the idle copy and waits for `open`. */
|
|
25
|
+
staffFirst?: boolean;
|
|
23
26
|
};
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type ReleaseStore } from './state.js';
|
|
2
|
+
/** The built-in apps Company Included releases from Archon and then copies to every customer org. */
|
|
3
|
+
export type FanOutApp = 'boule' | 'agora';
|
|
4
|
+
export type FanOutOrgState = 'waiting' | 'deploying' | 'done' | 'failed';
|
|
5
|
+
/** C1: what Archon shows for one app's fan-out. */
|
|
6
|
+
export interface BuiltinFanOut {
|
|
7
|
+
digest: string | null;
|
|
8
|
+
done: boolean;
|
|
9
|
+
orgs: Array<{
|
|
10
|
+
orgId: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
state: FanOutOrgState;
|
|
13
|
+
at: string | null;
|
|
14
|
+
reason: string | null;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
/** Applied-state scope keys of one app's fan-out (on the owner org). */
|
|
18
|
+
export declare function fanOutKeys(app: FanOutApp): {
|
|
19
|
+
/** The commit being copied. */
|
|
20
|
+
digest: string;
|
|
21
|
+
/** The commit whose copy finished on every org. */
|
|
22
|
+
done: string;
|
|
23
|
+
orgPrefix: string;
|
|
24
|
+
/** `<state>|<ISO time>|<org id>|<fixed reason>` */
|
|
25
|
+
org: (slug: string) => string;
|
|
26
|
+
};
|
|
27
|
+
/** C1: where the fan-out of the owner's Boule or Agora stands, from the owner org's applied state. */
|
|
28
|
+
export declare function readBuiltinFanOut(store: Pick<ReleaseStore, 'getScopeIds'>, ownerOrgId: string, app: FanOutApp): Promise<BuiltinFanOut>;
|
|
29
|
+
/**
|
|
30
|
+
* The commit the owner's copy runs when it is fully live: one colour at 100 %, the other at 0, no release
|
|
31
|
+
* running and no open step window. Null otherwise.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fullyLiveDigest(ids: Readonly<Record<string, string>>, app: FanOutApp): string | null;
|
|
34
|
+
export type FanOutDeployResult = {
|
|
35
|
+
ok: true;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: string;
|
|
39
|
+
};
|
|
40
|
+
export interface FanOutDeps {
|
|
41
|
+
ownerOrgId: string;
|
|
42
|
+
app: FanOutApp;
|
|
43
|
+
/** Whether the owner's `org.<app>.blueGreen` is declared. */
|
|
44
|
+
ownerDeclares: boolean;
|
|
45
|
+
store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests' | 'appendEvents'>;
|
|
46
|
+
/** The owner org's recorded ids now. */
|
|
47
|
+
readIds(): Promise<Record<string, string>>;
|
|
48
|
+
/** Persists scope keys on the owner org (null removes one), under a short lease. */
|
|
49
|
+
write(writes: Record<string, string | null>): Promise<void>;
|
|
50
|
+
/** Every other org that has the built-in app, in any order. Never the owner. */
|
|
51
|
+
targets(): Promise<Array<{
|
|
52
|
+
orgId: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
}>>;
|
|
55
|
+
/** Puts the org's copy on `sha` in place and waits for the deployment. Fixed-text reasons only. */
|
|
56
|
+
deploy(target: {
|
|
57
|
+
orgId: string;
|
|
58
|
+
slug: string;
|
|
59
|
+
}, sha: string): Promise<FanOutDeployResult>;
|
|
60
|
+
now: () => Date;
|
|
61
|
+
/** Each deploy is abandoned after this long (default 12 minutes). */
|
|
62
|
+
deployTimeoutMs?: number;
|
|
63
|
+
}
|
|
64
|
+
export declare const FANOUT_DEPLOY_TIMEOUT_MS: number;
|
|
65
|
+
export interface FanOutRun {
|
|
66
|
+
/** Why nothing deployed, or `deployed`, `done`, `stopped`. Fixed text. */
|
|
67
|
+
outcome: string;
|
|
68
|
+
deployed: string[];
|
|
69
|
+
failed: string | null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* One fan-out pass for one app (release tick, after the owner's release ran). Deploys each other org's copy at
|
|
73
|
+
* the owner's live commit, in slug order, and stops at the first failure. Every org is deployed in this pass
|
|
74
|
+
* unless one fails; a pass that dies halfway leaves the finished orgs `done` and the interrupted one `deploying`,
|
|
75
|
+
* which the next pass deploys again (a deploy of the same commit is safe to repeat).
|
|
76
|
+
*/
|
|
77
|
+
export declare function runBuiltinFanOut(deps: FanOutDeps): Promise<FanOutRun>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { recordedFrom } from './state.js';
|
|
2
|
+
const COMMIT = /^[0-9a-f]{40}$/;
|
|
3
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
4
|
+
const STATES = ['waiting', 'deploying', 'done', 'failed'];
|
|
5
|
+
/** Applied-state scope keys of one app's fan-out (on the owner org). */
|
|
6
|
+
export function fanOutKeys(app) {
|
|
7
|
+
const p = `fanout.${app}`;
|
|
8
|
+
return {
|
|
9
|
+
/** The commit being copied. */
|
|
10
|
+
digest: `${p}.digest`,
|
|
11
|
+
/** The commit whose copy finished on every org. */
|
|
12
|
+
done: `${p}.done`,
|
|
13
|
+
orgPrefix: `${p}.org.`,
|
|
14
|
+
/** `<state>|<ISO time>|<org id>|<fixed reason>` */
|
|
15
|
+
org: (slug) => `${p}.org.${slug}`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function assertApp(app) {
|
|
19
|
+
if (app !== 'boule' && app !== 'agora')
|
|
20
|
+
throw new Error('invalid fan-out app');
|
|
21
|
+
}
|
|
22
|
+
function parseOrg(ids, key, slug) {
|
|
23
|
+
if (!SLUG.test(slug))
|
|
24
|
+
return null;
|
|
25
|
+
const [state, at, orgId, ...rest] = (ids[key] ?? '').split('|');
|
|
26
|
+
if (!state || !STATES.includes(state) || !orgId)
|
|
27
|
+
return null;
|
|
28
|
+
return {
|
|
29
|
+
orgId,
|
|
30
|
+
slug,
|
|
31
|
+
state: state,
|
|
32
|
+
at: at || null,
|
|
33
|
+
reason: rest.join('|') || null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function readFrom(ids, app) {
|
|
37
|
+
const k = fanOutKeys(app);
|
|
38
|
+
const orgs = Object.keys(ids)
|
|
39
|
+
.filter((key) => key.startsWith(k.orgPrefix))
|
|
40
|
+
.map((key) => parseOrg(ids, key, key.slice(k.orgPrefix.length)))
|
|
41
|
+
.filter((o) => o !== null)
|
|
42
|
+
.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
43
|
+
const digest = ids[k.digest] ?? null;
|
|
44
|
+
return { digest, done: digest !== null && ids[k.done] === digest, orgs };
|
|
45
|
+
}
|
|
46
|
+
/** C1: where the fan-out of the owner's Boule or Agora stands, from the owner org's applied state. */
|
|
47
|
+
export async function readBuiltinFanOut(store, ownerOrgId, app) {
|
|
48
|
+
assertApp(app);
|
|
49
|
+
return readFrom(await store.getScopeIds(ownerOrgId), app);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The commit the owner's copy runs when it is fully live: one colour at 100 %, the other at 0, no release
|
|
53
|
+
* running and no open step window. Null otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function fullyLiveDigest(ids, app) {
|
|
56
|
+
const r = recordedFrom({ ...ids }, app);
|
|
57
|
+
if (!r || r.release !== null)
|
|
58
|
+
return null;
|
|
59
|
+
const other = r.live === 'blue' ? 'green' : 'blue';
|
|
60
|
+
if (r.weights[r.live] !== 100 || r.weights[other] !== 0)
|
|
61
|
+
return null;
|
|
62
|
+
if (!['idle', 'passed', 'rolled_back'].includes(r.window.kind))
|
|
63
|
+
return null;
|
|
64
|
+
const digest = r.digests[r.live];
|
|
65
|
+
return digest && COMMIT.test(digest) ? digest : null;
|
|
66
|
+
}
|
|
67
|
+
export const FANOUT_DEPLOY_TIMEOUT_MS = 12 * 60_000;
|
|
68
|
+
async function bounded(run, ms) {
|
|
69
|
+
let timer;
|
|
70
|
+
const timeout = new Promise((resolve) => {
|
|
71
|
+
timer = setTimeout(() => resolve({ ok: false, reason: 'deployment timed out' }), ms);
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
return await Promise.race([run, timeout]);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* One fan-out pass for one app (release tick, after the owner's release ran). Deploys each other org's copy at
|
|
82
|
+
* the owner's live commit, in slug order, and stops at the first failure. Every org is deployed in this pass
|
|
83
|
+
* unless one fails; a pass that dies halfway leaves the finished orgs `done` and the interrupted one `deploying`,
|
|
84
|
+
* which the next pass deploys again (a deploy of the same commit is safe to repeat).
|
|
85
|
+
*/
|
|
86
|
+
export async function runBuiltinFanOut(deps) {
|
|
87
|
+
const { app } = deps;
|
|
88
|
+
assertApp(app);
|
|
89
|
+
const k = fanOutKeys(app);
|
|
90
|
+
const none = (outcome) => ({ outcome, deployed: [], failed: null });
|
|
91
|
+
const retries = (await deps.store.openRequests({ orgId: deps.ownerOrgId, entryKey: app })).filter((r) => r.action.kind === 'fanout_retry');
|
|
92
|
+
if (retries.length > 0) {
|
|
93
|
+
const failedNow = readFrom(await deps.readIds(), app).orgs.filter((o) => o.state === 'failed');
|
|
94
|
+
const writes = {};
|
|
95
|
+
for (const o of failedNow)
|
|
96
|
+
writes[k.org(o.slug)] = `waiting|${deps.now().toISOString()}|${o.orgId}|`;
|
|
97
|
+
if (Object.keys(writes).length > 0)
|
|
98
|
+
await deps.write(writes);
|
|
99
|
+
await deps.store.consumeRequests(retries.map((r) => r.id));
|
|
100
|
+
}
|
|
101
|
+
if (!deps.ownerDeclares)
|
|
102
|
+
return none('owner has no blue/green copy');
|
|
103
|
+
const ids = await deps.readIds();
|
|
104
|
+
const digest = fullyLiveDigest(ids, app);
|
|
105
|
+
if (!digest)
|
|
106
|
+
return none('owner copy is not fully live on one commit');
|
|
107
|
+
if (ids[k.done] === digest)
|
|
108
|
+
return none('commit already copied');
|
|
109
|
+
const targets = [...(await deps.targets())]
|
|
110
|
+
.filter((t) => t.orgId !== deps.ownerOrgId && SLUG.test(t.slug))
|
|
111
|
+
.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
112
|
+
const stamp = deps.now().toISOString();
|
|
113
|
+
const current = readFrom(ids, app);
|
|
114
|
+
const fresh = current.digest !== digest;
|
|
115
|
+
const sync = {};
|
|
116
|
+
if (fresh) {
|
|
117
|
+
sync[k.digest] = digest;
|
|
118
|
+
sync[k.done] = null;
|
|
119
|
+
}
|
|
120
|
+
const states = new Map(current.orgs.map((o) => [o.slug, o]));
|
|
121
|
+
for (const o of current.orgs) {
|
|
122
|
+
if (!targets.some((t) => t.slug === o.slug))
|
|
123
|
+
sync[k.org(o.slug)] = null;
|
|
124
|
+
}
|
|
125
|
+
for (const t of targets) {
|
|
126
|
+
const have = states.get(t.slug);
|
|
127
|
+
if (fresh || !have || have.orgId !== t.orgId) {
|
|
128
|
+
sync[k.org(t.slug)] = `waiting|${stamp}|${t.orgId}|`;
|
|
129
|
+
states.set(t.slug, { ...t, state: 'waiting', at: stamp, reason: null });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (Object.keys(sync).length > 0)
|
|
133
|
+
await deps.write(sync);
|
|
134
|
+
const run = { outcome: 'done', deployed: [], failed: null };
|
|
135
|
+
for (const t of targets) {
|
|
136
|
+
const state = states.get(t.slug)?.state;
|
|
137
|
+
if (state === 'done')
|
|
138
|
+
continue;
|
|
139
|
+
if (state === 'failed')
|
|
140
|
+
return { ...run, outcome: 'stopped', failed: t.slug };
|
|
141
|
+
await deps.write({ [k.org(t.slug)]: `deploying|${deps.now().toISOString()}|${t.orgId}|` });
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await bounded(deps.deploy(t, digest), deps.deployTimeoutMs ?? FANOUT_DEPLOY_TIMEOUT_MS);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
console.error(`fanout ${app}: deploy threw ${error instanceof Error ? error.name : 'Error'}`);
|
|
148
|
+
result = { ok: false, reason: 'deployment failed' };
|
|
149
|
+
}
|
|
150
|
+
const at = deps.now().toISOString();
|
|
151
|
+
if (result.ok) {
|
|
152
|
+
await deps.write({ [k.org(t.slug)]: `done|${at}|${t.orgId}|` });
|
|
153
|
+
run.deployed.push(t.slug);
|
|
154
|
+
await record(deps, { at, kind: 'fanout_done', reason: t.slug });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const reason = result.reason.replace(/\|/g, ' ').slice(0, 120);
|
|
158
|
+
await deps.write({ [k.org(t.slug)]: `failed|${at}|${t.orgId}|${reason}` });
|
|
159
|
+
await record(deps, { at, kind: 'fanout_failed', reason: `${t.slug}: ${reason}` });
|
|
160
|
+
return { ...run, outcome: 'stopped', failed: t.slug };
|
|
161
|
+
}
|
|
162
|
+
await deps.write({ [k.done]: digest });
|
|
163
|
+
return run;
|
|
164
|
+
}
|
|
165
|
+
async function record(deps, event) {
|
|
166
|
+
try {
|
|
167
|
+
await deps.store.appendEvents({
|
|
168
|
+
orgId: deps.ownerOrgId,
|
|
169
|
+
entryKey: deps.app,
|
|
170
|
+
events: [event],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// The history is a record, not a gate: the state above already says what happened.
|
|
175
|
+
console.error(`fanout ${deps.app}: recording a history line failed`);
|
|
176
|
+
}
|
|
177
|
+
}
|
package/dist/release/run.js
CHANGED
|
@@ -28,7 +28,8 @@ export async function runRelease(input) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
const k = releaseKeys(entryKey);
|
|
31
|
-
|
|
31
|
+
// `fanout_retry` belongs to the built-in fan-out (`fanout.ts`), which consumes it; this run leaves it open.
|
|
32
|
+
const all = (await store.openRequests({ orgId, entryKey })).filter((r) => r.action.kind !== 'fanout_retry');
|
|
32
33
|
// A stage request whose result was persisted before a crash is consumed, never run a second time.
|
|
33
34
|
const handledId = ids[k.stageCopyHandled];
|
|
34
35
|
const replayed = all.filter((r) => isStageCopyAction(r.action) && String(r.id) === handledId);
|
package/dist/release/state.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export declare function releaseKeys(entry: string): {
|
|
|
66
66
|
};
|
|
67
67
|
export declare function formatWeights(w: Record<Color, number>): string;
|
|
68
68
|
export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
|
|
69
|
-
export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail"];
|
|
69
|
+
export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail", "fanout_done", "fanout_failed"];
|
|
70
70
|
/** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
|
|
71
71
|
export declare const releaseEventPayload: z.ZodObject<{
|
|
72
72
|
percent: z.ZodOptional<z.ZodNumber>;
|
|
@@ -100,6 +100,8 @@ export declare const releaseEventKind: z.ZodEnum<{
|
|
|
100
100
|
resume: "resume";
|
|
101
101
|
step_now: "step_now";
|
|
102
102
|
approve: "approve";
|
|
103
|
+
fanout_done: "fanout_done";
|
|
104
|
+
fanout_failed: "fanout_failed";
|
|
103
105
|
}>;
|
|
104
106
|
/** The window JSON is the C2 state plus the active release's `release` field. */
|
|
105
107
|
export declare function parseWindow(value: string | undefined): {
|
package/dist/release/state.js
CHANGED
|
@@ -78,6 +78,8 @@ export const RELEASE_EVENT_KINDS = [
|
|
|
78
78
|
'rollback',
|
|
79
79
|
'passed',
|
|
80
80
|
'hard_fail',
|
|
81
|
+
'fanout_done',
|
|
82
|
+
'fanout_failed',
|
|
81
83
|
];
|
|
82
84
|
/** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
|
|
83
85
|
export const releaseEventPayload = z.object({
|
|
@@ -216,6 +218,7 @@ const actionSchema = z.discriminatedUnion('kind', [
|
|
|
216
218
|
z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
|
|
217
219
|
z.object({ kind: z.literal('copy_prod_to_stage') }),
|
|
218
220
|
z.object({ kind: z.literal('undo_stage_copy') }),
|
|
221
|
+
z.object({ kind: z.literal('fanout_retry') }),
|
|
219
222
|
]);
|
|
220
223
|
/** C2: ask the runner to apply an action on its next run. Archon audits it. */
|
|
221
224
|
export async function requestReleaseAction(store, input) {
|
package/dist/release/types.d.ts
CHANGED
|
@@ -107,8 +107,16 @@ export type ReleaseAction = {
|
|
|
107
107
|
kind: 'copy_prod_to_stage';
|
|
108
108
|
} | {
|
|
109
109
|
kind: 'undo_stage_copy';
|
|
110
|
+
}
|
|
111
|
+
/** Owner org, entry key `boule` or `agora`: continue the built-in fan-out from the org that failed. */
|
|
112
|
+
| {
|
|
113
|
+
kind: 'fanout_retry';
|
|
110
114
|
};
|
|
111
|
-
export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail'
|
|
115
|
+
export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail'
|
|
116
|
+
/** The built-in fan-out put an org's copy on the live commit (`reason` is the org slug). */
|
|
117
|
+
| 'fanout_done'
|
|
118
|
+
/** The built-in fan-out stopped at an org (`reason` is `<slug>: <fixed text>`). */
|
|
119
|
+
| 'fanout_failed';
|
|
112
120
|
/** C1: one line of a release's history, written by the release run (`foundry_release_events`). */
|
|
113
121
|
export interface ReleaseEvent {
|
|
114
122
|
at: string;
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -2173,6 +2173,8 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
2173
2173
|
data: {
|
|
2174
2174
|
steps?: number[];
|
|
2175
2175
|
stepHoldMinutes?: number;
|
|
2176
|
+
advance?: "time" | "signal" | "approval";
|
|
2177
|
+
staffFirst?: boolean;
|
|
2176
2178
|
} | null;
|
|
2177
2179
|
driverParam: unknown;
|
|
2178
2180
|
notNull: false;
|
|
@@ -2188,6 +2190,8 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
2188
2190
|
$type: {
|
|
2189
2191
|
steps?: number[];
|
|
2190
2192
|
stepHoldMinutes?: number;
|
|
2193
|
+
advance?: "time" | "signal" | "approval";
|
|
2194
|
+
staffFirst?: boolean;
|
|
2191
2195
|
} | null;
|
|
2192
2196
|
}>;
|
|
2193
2197
|
edgeEnabled: import("drizzle-orm/pg-core").PgColumn<{
|