@wtfalch/foundry 0.70.0 → 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/bootstrap/declare.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/migrations/0070_platform_usage_tail.sql +12 -0
- package/dist/plan/diff.js +23 -7
- package/dist/plan/edge-access.d.ts +9 -0
- package/dist/plan/edge-access.js +20 -0
- package/dist/plan/executor.d.ts +16 -0
- package/dist/plan/executor.js +61 -10
- package/dist/plan/own-diff.js +4 -1
- package/dist/plan/plan-apply.d.ts +23 -0
- package/dist/plan/plan-apply.js +70 -0
- package/dist/plan/platform-diff.d.ts +62 -0
- package/dist/plan/platform-diff.js +165 -4
- package/dist/plan/platform-providers.d.ts +16 -0
- package/dist/plan/platform-providers.js +86 -4
- package/dist/plan/usage-tail-worker.d.ts +29 -0
- package/dist/plan/usage-tail-worker.js +105 -0
- package/dist/platform.d.ts +23 -0
- package/dist/platform.js +14 -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/postgres.js +8 -0
- package/dist/store/schema.d.ts +55 -0
- package/dist/store/schema.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { usageTailArtifactUrl } from '../platform.js';
|
|
3
|
+
import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY, PLATFORM_USAGE_TAIL_KEY_REF, PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, PLATFORM_USAGE_TAIL_SCRIPT_NAME, } from './platform-diff.js';
|
|
4
|
+
/** The date Cloudflare's Workers runtime compatibility is pinned to; no Workers feature here needs a later one. */
|
|
5
|
+
const USAGE_TAIL_COMPATIBILITY_DATE = '2026-10-01';
|
|
6
|
+
/**
|
|
7
|
+
* foundry#1024: makes the usage tail Worker true. Upload the pinned edge
|
|
8
|
+
* release (the host fetches and verifies it) and record the script at once,
|
|
9
|
+
* so a later failure is a half-made Worker foundry owns. Then hold the key
|
|
10
|
+
* (minted once, its guard recorded as soon as its value is held) and set it
|
|
11
|
+
* on the Worker. The applied values are recorded only when all of it
|
|
12
|
+
* succeeded.
|
|
13
|
+
*/
|
|
14
|
+
export async function provisionPlatformUsageTail(context, deps) {
|
|
15
|
+
const { platform } = context;
|
|
16
|
+
const { usageTail } = platform;
|
|
17
|
+
const recorded = context.recordedIds();
|
|
18
|
+
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
19
|
+
if (!edgeUrl) {
|
|
20
|
+
throw new Error(`platform:usage-tail: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); the Worker is uploaded through it`);
|
|
21
|
+
}
|
|
22
|
+
const workers = await deps.workers(edgeUrl);
|
|
23
|
+
if (!usageTail.enabled) {
|
|
24
|
+
// Whatever of a (possibly half-made) deploy exists is removed; what does not exist is tolerated.
|
|
25
|
+
await workers.deleteScript(PLATFORM_USAGE_TAIL_SCRIPT_NAME);
|
|
26
|
+
await deps.deliverSecret(PLATFORM_USAGE_TAIL_KEY_REF, '');
|
|
27
|
+
for (const key of [
|
|
28
|
+
PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY,
|
|
29
|
+
PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY,
|
|
30
|
+
PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY,
|
|
31
|
+
PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY,
|
|
32
|
+
]) {
|
|
33
|
+
context.recordScopeId(key, '');
|
|
34
|
+
}
|
|
35
|
+
context.recordScopeId(PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, '1');
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
if (!usageTail.tag || !usageTail.sha256) {
|
|
39
|
+
throw new Error('platform:usage-tail: no artifact is pinned (usageTail.tag, usageTail.sha256)');
|
|
40
|
+
}
|
|
41
|
+
// ---- the key, once ------------------------------------------------------
|
|
42
|
+
// The hash is never secret, so (the same pattern `provisionPlatformEdge`
|
|
43
|
+
// uses for `EDGE_PLATFORM_KEY_HASH`) it doubles as both the undelivered
|
|
44
|
+
// marker and the final recorded value: a marker with no guard means the
|
|
45
|
+
// mint may never have reached `deliverSecret`, so it is tried again with
|
|
46
|
+
// the same hash rather than minting a second key.
|
|
47
|
+
let keyHash = recorded[PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY] ?? '';
|
|
48
|
+
if (!keyHash) {
|
|
49
|
+
keyHash = recorded[PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY] ?? '';
|
|
50
|
+
let secret = keyHash ? await deps.heldKeys.resolve(PLATFORM_USAGE_TAIL_KEY_REF) : null;
|
|
51
|
+
if (!secret) {
|
|
52
|
+
secret = randomBytes(32).toString('base64url');
|
|
53
|
+
keyHash = createHash('sha256').update(secret).digest('hex');
|
|
54
|
+
context.recordScopeId(PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, keyHash);
|
|
55
|
+
await context.persist?.();
|
|
56
|
+
}
|
|
57
|
+
await deliverKey(deps, secret);
|
|
58
|
+
context.recordScopeId(PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, keyHash);
|
|
59
|
+
await context.persist?.();
|
|
60
|
+
}
|
|
61
|
+
// ---- the script ----------------------------------------------------------
|
|
62
|
+
await workers.uploadScript({
|
|
63
|
+
name: PLATFORM_USAGE_TAIL_SCRIPT_NAME,
|
|
64
|
+
artifactUrl: usageTailArtifactUrl(usageTail.tag),
|
|
65
|
+
artifactSha256: usageTail.sha256,
|
|
66
|
+
compatibilityDate: USAGE_TAIL_COMPATIBILITY_DATE,
|
|
67
|
+
bindings: [{ type: 'plain_text', name: 'EDGE_USAGE_URL', text: edgeUrl }],
|
|
68
|
+
});
|
|
69
|
+
// The script is foundry's from here on. Recorded now, not at the end: after
|
|
70
|
+
// a later failure the next plan must repair this Worker, not take it for a
|
|
71
|
+
// foreign one. "Fully applied" is the applied values recorded at the end.
|
|
72
|
+
context.recordScopeId(`cloudflare:worker:${PLATFORM_USAGE_TAIL_KEY}`, PLATFORM_USAGE_TAIL_SCRIPT_NAME);
|
|
73
|
+
context.recordScopeId(PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, '');
|
|
74
|
+
await context.persist?.();
|
|
75
|
+
// The key itself is read back only to hand it to the Worker (rule 6: it
|
|
76
|
+
// never reaches an error, a log, or a plan item); edge never returns it.
|
|
77
|
+
const secretValue = await deps.heldKeys.resolve(PLATFORM_USAGE_TAIL_KEY_REF);
|
|
78
|
+
if (!secretValue) {
|
|
79
|
+
throw new Error(`platform:usage-tail: the held key (${PLATFORM_USAGE_TAIL_KEY_REF}) is gone after it was minted; hold a new one to recover`);
|
|
80
|
+
}
|
|
81
|
+
await workers.setSecrets(PLATFORM_USAGE_TAIL_SCRIPT_NAME, [
|
|
82
|
+
{ name: 'EDGE_USAGE_KEY', value: secretValue, version: keyHash },
|
|
83
|
+
]);
|
|
84
|
+
// Last, after every write the applied values depend on.
|
|
85
|
+
context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, usageTail.tag);
|
|
86
|
+
context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, usageTail.sha256);
|
|
87
|
+
return { resourceId: PLATFORM_USAGE_TAIL_SCRIPT_NAME };
|
|
88
|
+
}
|
|
89
|
+
/** Rule 1: delivery is tried up to `deliverAttempts` times; the error names the ref only, never the cause. */
|
|
90
|
+
async function deliverKey(deps, secret) {
|
|
91
|
+
const attempts = deps.deliverAttempts ?? 3;
|
|
92
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
93
|
+
for (let attempt = 1;; attempt += 1) {
|
|
94
|
+
try {
|
|
95
|
+
await deps.deliverSecret(PLATFORM_USAGE_TAIL_KEY_REF, secret);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
if (attempt >= attempts) {
|
|
100
|
+
throw new Error(`platform:usage-tail: the key (${PLATFORM_USAGE_TAIL_KEY_REF}) could not be delivered after ${attempts} tries; it went nowhere, so the next apply mints a new one`);
|
|
101
|
+
}
|
|
102
|
+
await sleep(1000 * attempt);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
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
|
/**
|
|
@@ -66,6 +69,24 @@ export interface PlatformUptimeWorker {
|
|
|
66
69
|
}
|
|
67
70
|
/** Where Archon publishes the Worker script for `tag` (a private repo; the host's `fetchArtifact` fetches it). */
|
|
68
71
|
export declare function uptimeArtifactUrl(tag: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* foundry#1024: the usage tail Worker that meters every company Worker's
|
|
74
|
+
* use. `@wtfalch/edge`'s `packages/service/src/usage-tail-worker.ts` has no
|
|
75
|
+
* imports and builds to `usage-tail-worker.js`; foundry deploys the pinned
|
|
76
|
+
* build and never builds it, the same `tag`/`sha256` pin
|
|
77
|
+
* {@link PlatformUptimeWorker} uses. Both empty means nothing is pinned yet,
|
|
78
|
+
* and the planner reports that instead of planning the step. It has no
|
|
79
|
+
* hostname: it answers no request, only the tail events Cloudflare delivers.
|
|
80
|
+
*/
|
|
81
|
+
export interface PlatformUsageTailWorker {
|
|
82
|
+
enabled: boolean;
|
|
83
|
+
/** An edge release tag, `v1.2.3`. */
|
|
84
|
+
tag: string;
|
|
85
|
+
/** Lower-case hex SHA-256 of `usage-tail-worker.js` at that tag. */
|
|
86
|
+
sha256: string;
|
|
87
|
+
}
|
|
88
|
+
/** Where edge publishes the Worker script for `tag` (the host's `fetchArtifact` fetches it). */
|
|
89
|
+
export declare function usageTailArtifactUrl(tag: string): string;
|
|
69
90
|
/**
|
|
70
91
|
* The shared services a declared platform names, decided in #165, plus
|
|
71
92
|
* `edge` (#317, F7): the service that mints an entry's own edge key and
|
|
@@ -137,6 +158,8 @@ export interface DeclaredPlatform {
|
|
|
137
158
|
coolify: PlatformService;
|
|
138
159
|
/** foundry#542: the outside prober Worker; see {@link PlatformUptimeWorker}. */
|
|
139
160
|
uptime: PlatformUptimeWorker;
|
|
161
|
+
/** foundry#1024: the usage tail Worker; see {@link PlatformUsageTailWorker}. */
|
|
162
|
+
usageTail: PlatformUsageTailWorker;
|
|
140
163
|
/**
|
|
141
164
|
* foundry#544: which Healthchecks instance manages the heartbeat checks
|
|
142
165
|
* for every piece of scheduled work foundry sets up (the uptime Worker,
|
package/dist/platform.js
CHANGED
|
@@ -4,6 +4,10 @@ import { validateBlueGreen, validateDomain } from './org.js';
|
|
|
4
4
|
export function uptimeArtifactUrl(tag) {
|
|
5
5
|
return `https://github.com/wtfalch/archon/releases/download/${tag}/archon-uptime-worker.js`;
|
|
6
6
|
}
|
|
7
|
+
/** Where edge publishes the Worker script for `tag` (the host's `fetchArtifact` fetches it). */
|
|
8
|
+
export function usageTailArtifactUrl(tag) {
|
|
9
|
+
return `https://github.com/wtfalch/edge/releases/download/${tag}/usage-tail-worker.js`;
|
|
10
|
+
}
|
|
7
11
|
export const PLATFORM_SERVICE_KEYS = [
|
|
8
12
|
'auth',
|
|
9
13
|
'mail',
|
|
@@ -87,6 +91,16 @@ export function validatePlatform(platform) {
|
|
|
87
91
|
if (uptime.sha256 && !/^[0-9a-f]{64}$/.test(uptime.sha256)) {
|
|
88
92
|
return 'uptime.sha256 must be 64 lower-case hex characters';
|
|
89
93
|
}
|
|
94
|
+
const { usageTail } = platform;
|
|
95
|
+
if (Boolean(usageTail.tag.trim()) !== Boolean(usageTail.sha256.trim())) {
|
|
96
|
+
return 'usageTail.tag and usageTail.sha256 must be set together: a pin is both';
|
|
97
|
+
}
|
|
98
|
+
if (usageTail.tag && !/^v[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(usageTail.tag)) {
|
|
99
|
+
return `usageTail.tag '${usageTail.tag}' must be a release tag such as v1.2.3`;
|
|
100
|
+
}
|
|
101
|
+
if (usageTail.sha256 && !/^[0-9a-f]{64}$/.test(usageTail.sha256)) {
|
|
102
|
+
return 'usageTail.sha256 must be 64 lower-case hex characters';
|
|
103
|
+
}
|
|
90
104
|
// #491: declared, never discovered -- a present-but-blank id is refused the
|
|
91
105
|
// same way `serverIpv4`'s blank string is, rather than being read as unset.
|
|
92
106
|
if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
|
|
@@ -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/postgres.js
CHANGED
|
@@ -815,6 +815,11 @@ function rowToPlatform(row) {
|
|
|
815
815
|
gitRef: row.coolifyGitRef,
|
|
816
816
|
},
|
|
817
817
|
uptime: { enabled: row.uptimeEnabled, tag: row.uptimeTag, sha256: row.uptimeSha256 },
|
|
818
|
+
usageTail: {
|
|
819
|
+
enabled: row.usageTailEnabled,
|
|
820
|
+
tag: row.usageTailTag,
|
|
821
|
+
sha256: row.usageTailSha256,
|
|
822
|
+
},
|
|
818
823
|
heartbeatsProvider: row.heartbeatsProvider,
|
|
819
824
|
};
|
|
820
825
|
}
|
|
@@ -867,6 +872,9 @@ function platformToRow(platform) {
|
|
|
867
872
|
uptimeEnabled: platform.uptime.enabled,
|
|
868
873
|
uptimeTag: platform.uptime.tag,
|
|
869
874
|
uptimeSha256: platform.uptime.sha256,
|
|
875
|
+
usageTailEnabled: platform.usageTail.enabled,
|
|
876
|
+
usageTailTag: platform.usageTail.tag,
|
|
877
|
+
usageTailSha256: platform.usageTail.sha256,
|
|
870
878
|
heartbeatsProvider: platform.heartbeatsProvider,
|
|
871
879
|
};
|
|
872
880
|
}
|
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<{
|
|
@@ -2445,6 +2449,57 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
2445
2449
|
identity: undefined;
|
|
2446
2450
|
generated: undefined;
|
|
2447
2451
|
}, {}, {}>;
|
|
2452
|
+
usageTailEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
2453
|
+
name: "usage_tail_enabled";
|
|
2454
|
+
tableName: "foundry_declared_platform";
|
|
2455
|
+
dataType: "boolean";
|
|
2456
|
+
columnType: "PgBoolean";
|
|
2457
|
+
data: boolean;
|
|
2458
|
+
driverParam: boolean;
|
|
2459
|
+
notNull: true;
|
|
2460
|
+
hasDefault: true;
|
|
2461
|
+
isPrimaryKey: false;
|
|
2462
|
+
isAutoincrement: false;
|
|
2463
|
+
hasRuntimeDefault: false;
|
|
2464
|
+
enumValues: undefined;
|
|
2465
|
+
baseColumn: never;
|
|
2466
|
+
identity: undefined;
|
|
2467
|
+
generated: undefined;
|
|
2468
|
+
}, {}, {}>;
|
|
2469
|
+
usageTailTag: import("drizzle-orm/pg-core").PgColumn<{
|
|
2470
|
+
name: "usage_tail_tag";
|
|
2471
|
+
tableName: "foundry_declared_platform";
|
|
2472
|
+
dataType: "string";
|
|
2473
|
+
columnType: "PgText";
|
|
2474
|
+
data: string;
|
|
2475
|
+
driverParam: string;
|
|
2476
|
+
notNull: true;
|
|
2477
|
+
hasDefault: true;
|
|
2478
|
+
isPrimaryKey: false;
|
|
2479
|
+
isAutoincrement: false;
|
|
2480
|
+
hasRuntimeDefault: false;
|
|
2481
|
+
enumValues: [string, ...string[]];
|
|
2482
|
+
baseColumn: never;
|
|
2483
|
+
identity: undefined;
|
|
2484
|
+
generated: undefined;
|
|
2485
|
+
}, {}, {}>;
|
|
2486
|
+
usageTailSha256: import("drizzle-orm/pg-core").PgColumn<{
|
|
2487
|
+
name: "usage_tail_sha256";
|
|
2488
|
+
tableName: "foundry_declared_platform";
|
|
2489
|
+
dataType: "string";
|
|
2490
|
+
columnType: "PgText";
|
|
2491
|
+
data: string;
|
|
2492
|
+
driverParam: string;
|
|
2493
|
+
notNull: true;
|
|
2494
|
+
hasDefault: true;
|
|
2495
|
+
isPrimaryKey: false;
|
|
2496
|
+
isAutoincrement: false;
|
|
2497
|
+
hasRuntimeDefault: false;
|
|
2498
|
+
enumValues: [string, ...string[]];
|
|
2499
|
+
baseColumn: never;
|
|
2500
|
+
identity: undefined;
|
|
2501
|
+
generated: undefined;
|
|
2502
|
+
}, {}, {}>;
|
|
2448
2503
|
heartbeatsProvider: import("drizzle-orm/pg-core").PgColumn<{
|
|
2449
2504
|
name: "heartbeats_provider";
|
|
2450
2505
|
tableName: "foundry_declared_platform";
|
package/dist/store/schema.js
CHANGED
|
@@ -231,6 +231,10 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
|
|
|
231
231
|
uptimeEnabled: boolean('uptime_enabled').notNull().default(true),
|
|
232
232
|
uptimeTag: text('uptime_tag').notNull().default(''),
|
|
233
233
|
uptimeSha256: text('uptime_sha256').notNull().default(''),
|
|
234
|
+
/** foundry#1024, `migrations/0070_platform_usage_tail.sql`: the usage tail Worker and its artifact pin. */
|
|
235
|
+
usageTailEnabled: boolean('usage_tail_enabled').notNull().default(false),
|
|
236
|
+
usageTailTag: text('usage_tail_tag').notNull().default(''),
|
|
237
|
+
usageTailSha256: text('usage_tail_sha256').notNull().default(''),
|
|
234
238
|
/** foundry#544, `migrations/0053_heartbeats_provider.sql`: which Healthchecks instance manages the heartbeat checks, decoupled from `hcEnabled` above. */
|
|
235
239
|
heartbeatsProvider: text('heartbeats_provider').notNull().default('self-hosted'),
|
|
236
240
|
/** `migrations/0018_platform_apply.sql`: the platform's applied state, `system:kind:key` to provider id. */
|