@wtfalch/foundry 0.42.1 → 0.44.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 +8 -4
- package/dist/index.js +4 -2
- package/dist/migrations/0055_release_requests.sql +20 -0
- package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
- package/dist/migrations/0057_org_customers.sql +29 -0
- package/dist/org.d.ts +55 -0
- package/dist/org.js +90 -27
- 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 +43 -2
- package/dist/plan/boule.js +48 -1
- package/dist/plan/broker-entries.d.ts +28 -0
- package/dist/plan/broker-entries.js +46 -1
- package/dist/plan/cloudflared-connector.d.ts +26 -0
- package/dist/plan/cloudflared-connector.js +37 -0
- package/dist/plan/diff.d.ts +20 -0
- package/dist/plan/diff.js +207 -20
- package/dist/plan/executor.d.ts +134 -2
- package/dist/plan/executor.js +517 -25
- package/dist/plan/hostnames.d.ts +7 -0
- package/dist/plan/hostnames.js +17 -2
- package/dist/plan/locked-hostnames.d.ts +11 -0
- package/dist/plan/locked-hostnames.js +13 -0
- package/dist/plan/mcp.d.ts +57 -0
- package/dist/plan/mcp.js +131 -0
- package/dist/plan/org-names.d.ts +2 -2
- package/dist/plan/org-names.js +16 -1
- package/dist/plan/plan-apply.d.ts +77 -0
- package/dist/plan/plan-apply.js +267 -5
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +24 -1
- package/dist/plan/platform-providers.d.ts +15 -1
- package/dist/plan/platform-providers.js +90 -5
- package/dist/plan/proxy-labels.d.ts +2 -2
- package/dist/plan/proxy-labels.js +14 -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/worker-app-database.d.ts +148 -0
- package/dist/plan/worker-app-database.js +424 -0
- package/dist/plan/worker-app.d.ts +320 -0
- package/dist/plan/worker-app.js +792 -0
- package/dist/plan/zitadel-machine-user.d.ts +13 -1
- package/dist/plan/zitadel-machine-user.js +80 -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/requested-changes.d.ts +12 -3
- package/dist/requested-changes.js +22 -0
- package/dist/store/postgres.d.ts +8 -0
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +188 -0
- package/dist/store/schema.js +15 -0
- package/dist/worker-app-config.d.ts +151 -0
- package/dist/worker-app-config.js +438 -0
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- package/versions.json +2 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { decide, healthRange, } from './decide.js';
|
|
2
|
+
import { formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
|
|
3
|
+
/**
|
|
4
|
+
* One run for one entry. Reads the recorded keys fresh and the open
|
|
5
|
+
* requests (it consumes none), decides and runs the effects. A failure, or a
|
|
6
|
+
* crash before the caller persists and consumes, leaves the requests open and
|
|
7
|
+
* the recorded keys as they were: the next run decides again from them.
|
|
8
|
+
*/
|
|
9
|
+
export async function runRelease(input) {
|
|
10
|
+
const { store, orgId, entryKey, deps, now } = input;
|
|
11
|
+
const ids = await store.getScopeIds(orgId);
|
|
12
|
+
const recorded = recordedFrom(ids, entryKey);
|
|
13
|
+
if (!recorded)
|
|
14
|
+
return { writes: {}, blocked: [], refused: [], decision: null, requestIds: [] };
|
|
15
|
+
const open = await store.openRequests({ orgId, entryKey });
|
|
16
|
+
const actions = open.map((r) => r.action);
|
|
17
|
+
const range = healthRange(recorded, now);
|
|
18
|
+
const target = recorded.release?.to ?? null;
|
|
19
|
+
const health = range ? await deps.readHealth(range) : null;
|
|
20
|
+
const seen = target
|
|
21
|
+
? await deps.observeColor(target)
|
|
22
|
+
: { status: 'other', address: 'unchecked' };
|
|
23
|
+
const decision = decide({
|
|
24
|
+
recorded,
|
|
25
|
+
actions,
|
|
26
|
+
steps: input.entry.steps,
|
|
27
|
+
stepHoldMinutes: input.entry.stepHoldMinutes,
|
|
28
|
+
now,
|
|
29
|
+
observation: { health, ...seen },
|
|
30
|
+
});
|
|
31
|
+
for (const effect of decision.effects)
|
|
32
|
+
await deps.effect(effect);
|
|
33
|
+
const k = releaseKeys(entryKey);
|
|
34
|
+
const next = decision.recorded;
|
|
35
|
+
const writes = {
|
|
36
|
+
[k.live]: next.live,
|
|
37
|
+
[k.weights]: formatWeights(next.weights),
|
|
38
|
+
[k.digest('blue')]: next.digests.blue,
|
|
39
|
+
[k.digest('green')]: next.digests.green,
|
|
40
|
+
[k.window]: formatWindow(next.window, next.release),
|
|
41
|
+
[k.signal]: decision.signal ? JSON.stringify(decision.signal) : null,
|
|
42
|
+
};
|
|
43
|
+
for (const d of recorded.held)
|
|
44
|
+
if (!next.held.includes(d))
|
|
45
|
+
writes[k.held(d)] = null;
|
|
46
|
+
for (const d of next.held)
|
|
47
|
+
if (!recorded.held.includes(d))
|
|
48
|
+
writes[k.held(d)] = now.toISOString();
|
|
49
|
+
return {
|
|
50
|
+
writes,
|
|
51
|
+
blocked: decision.blocked,
|
|
52
|
+
refused: decision.refused,
|
|
53
|
+
decision,
|
|
54
|
+
requestIds: open.map((r) => r.id),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Recorded } from './decide.js';
|
|
3
|
+
import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* The persistence the release runner and Archon share. `getScopeIds` is the
|
|
6
|
+
* recorded `bg.*` keys of an org's applied state (or of the platform, for the
|
|
7
|
+
* orgId `platform`). Requests live in their own table so a click never races
|
|
8
|
+
* the runner's snapshot write.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReleaseStore {
|
|
11
|
+
getScopeIds(orgId: string): Promise<Record<string, string>>;
|
|
12
|
+
addRequest(input: {
|
|
13
|
+
orgId: string;
|
|
14
|
+
entryKey: string;
|
|
15
|
+
action: ReleaseAction;
|
|
16
|
+
actor: string;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
/** The open requests, oldest first. Reading consumes nothing: a run that fails leaves them open. */
|
|
19
|
+
openRequests(input: {
|
|
20
|
+
orgId: string;
|
|
21
|
+
entryKey: string;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
id: number;
|
|
24
|
+
action: ReleaseAction;
|
|
25
|
+
actor: string;
|
|
26
|
+
}[]>;
|
|
27
|
+
/** Marks requests consumed. The runner calls it only after its decision is persisted. */
|
|
28
|
+
consumeRequests(ids: readonly number[]): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
|
|
31
|
+
export declare function releaseKeys(entry: string): {
|
|
32
|
+
live: string;
|
|
33
|
+
digest: (c: Color) => string;
|
|
34
|
+
weights: string;
|
|
35
|
+
stage: string;
|
|
36
|
+
window: string;
|
|
37
|
+
signal: string;
|
|
38
|
+
heldPrefix: string;
|
|
39
|
+
held: (digest: string) => string;
|
|
40
|
+
};
|
|
41
|
+
export declare function formatWeights(w: Record<Color, number>): string;
|
|
42
|
+
export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
|
|
43
|
+
/** The window JSON is the C2 state plus the active release's `release` field. */
|
|
44
|
+
export declare function parseWindow(value: string | undefined): {
|
|
45
|
+
window: WindowState;
|
|
46
|
+
release: Recorded['release'];
|
|
47
|
+
};
|
|
48
|
+
export declare function formatWindow(window: WindowState, release: Recorded['release']): string;
|
|
49
|
+
export declare function recordedFrom(ids: Record<string, string>, entryKey: string): Recorded | null;
|
|
50
|
+
export declare function readSignal(ids: Record<string, string>, entryKey: string): ReleaseSignal | null;
|
|
51
|
+
/** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
|
|
52
|
+
export declare function readReleaseState(store: ReleaseStore, input: {
|
|
53
|
+
orgId: string;
|
|
54
|
+
entryKey: string;
|
|
55
|
+
}): Promise<ReleaseState | null>;
|
|
56
|
+
declare const actionSchema: z.ZodType<ReleaseAction>;
|
|
57
|
+
/** C2: ask the runner to apply an action on its next run. Archon audits it. */
|
|
58
|
+
export declare function requestReleaseAction(store: ReleaseStore, input: {
|
|
59
|
+
orgId: string;
|
|
60
|
+
entryKey: string;
|
|
61
|
+
action: ReleaseAction;
|
|
62
|
+
actor: string;
|
|
63
|
+
}): Promise<void>;
|
|
64
|
+
export { actionSchema as releaseActionSchema };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
|
|
3
|
+
export function releaseKeys(entry) {
|
|
4
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(entry))
|
|
5
|
+
throw new Error('invalid release entry key');
|
|
6
|
+
const p = `bg.${entry}`;
|
|
7
|
+
return {
|
|
8
|
+
live: `${p}.live`,
|
|
9
|
+
digest: (c) => `${p}.digest.${c}`,
|
|
10
|
+
weights: `${p}.weights`,
|
|
11
|
+
stage: `${p}.stage`,
|
|
12
|
+
window: `${p}.window`,
|
|
13
|
+
signal: `${p}.signal`,
|
|
14
|
+
heldPrefix: `${p}.held.`,
|
|
15
|
+
held: (digest) => `${p}.held.${digest}`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function formatWeights(w) {
|
|
19
|
+
return `blue:${w.blue},green:${w.green}`;
|
|
20
|
+
}
|
|
21
|
+
export function parseWeights(value) {
|
|
22
|
+
const m = /^blue:(\d{1,3}),green:(\d{1,3})$/.exec(value ?? '');
|
|
23
|
+
if (!m)
|
|
24
|
+
return null;
|
|
25
|
+
return { blue: Number(m[1]), green: Number(m[2]) };
|
|
26
|
+
}
|
|
27
|
+
const color = z.enum(['blue', 'green']);
|
|
28
|
+
const storedWindow = z.discriminatedUnion('kind', [
|
|
29
|
+
z.object({ kind: z.literal('idle') }),
|
|
30
|
+
z.object({ kind: z.literal('step'), percent: z.number(), since: z.string() }),
|
|
31
|
+
z.object({ kind: z.literal('inconclusive'), percent: z.number(), since: z.string() }),
|
|
32
|
+
z.object({ kind: z.literal('window'), since: z.string() }),
|
|
33
|
+
z.object({ kind: z.literal('passed'), at: z.string() }),
|
|
34
|
+
z.object({ kind: z.literal('rolled_back'), at: z.string(), reason: z.string() }),
|
|
35
|
+
z.object({ kind: z.literal('cap'), since: z.string() }),
|
|
36
|
+
]);
|
|
37
|
+
const releaseField = z
|
|
38
|
+
.object({
|
|
39
|
+
from: color,
|
|
40
|
+
to: color,
|
|
41
|
+
digest: z.string(),
|
|
42
|
+
fails: z.number().int().nonnegative(),
|
|
43
|
+
checkedAt: z.string().optional(),
|
|
44
|
+
})
|
|
45
|
+
.nullable()
|
|
46
|
+
.optional();
|
|
47
|
+
const signalSchema = z.object({
|
|
48
|
+
requests: z.number(),
|
|
49
|
+
errors: z.number(),
|
|
50
|
+
baselineRate: z.number().nullable(),
|
|
51
|
+
verdict: z.enum(['pass', 'fail', 'hard_fail', 'inconclusive']),
|
|
52
|
+
});
|
|
53
|
+
function parseJson(value) {
|
|
54
|
+
if (!value)
|
|
55
|
+
return undefined;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(value);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// A damaged value reads as absent; the runner rewrites it.
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The window JSON is the C2 state plus the active release's `release` field. */
|
|
65
|
+
export function parseWindow(value) {
|
|
66
|
+
const state = storedWindow.safeParse(parseJson(value));
|
|
67
|
+
if (!state.success)
|
|
68
|
+
return { window: { kind: 'idle' }, release: null };
|
|
69
|
+
const rel = releaseField.safeParse(parseJson(value).release);
|
|
70
|
+
return { window: state.data, release: rel.success ? (rel.data ?? null) : null };
|
|
71
|
+
}
|
|
72
|
+
export function formatWindow(window, release) {
|
|
73
|
+
return JSON.stringify(release ? { ...window, release } : window);
|
|
74
|
+
}
|
|
75
|
+
export function recordedFrom(ids, entryKey) {
|
|
76
|
+
const k = releaseKeys(entryKey);
|
|
77
|
+
const live = color.safeParse(ids[k.live]);
|
|
78
|
+
if (!live.success)
|
|
79
|
+
return null;
|
|
80
|
+
const { window, release } = parseWindow(ids[k.window]);
|
|
81
|
+
return {
|
|
82
|
+
live: live.data,
|
|
83
|
+
digests: { blue: ids[k.digest('blue')] ?? null, green: ids[k.digest('green')] ?? null },
|
|
84
|
+
stageDigest: ids[k.stage] ?? null,
|
|
85
|
+
weights: parseWeights(ids[k.weights]) ?? { ...{ blue: 0, green: 0 }, [live.data]: 100 },
|
|
86
|
+
window,
|
|
87
|
+
release,
|
|
88
|
+
held: Object.keys(ids)
|
|
89
|
+
.filter((key) => key.startsWith(k.heldPrefix))
|
|
90
|
+
.map((key) => key.slice(k.heldPrefix.length))
|
|
91
|
+
.sort(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function readSignal(ids, entryKey) {
|
|
95
|
+
const parsed = signalSchema.safeParse(parseJson(ids[releaseKeys(entryKey).signal]));
|
|
96
|
+
return parsed.success ? parsed.data : null;
|
|
97
|
+
}
|
|
98
|
+
/** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
|
|
99
|
+
export async function readReleaseState(store, input) {
|
|
100
|
+
const ids = await store.getScopeIds(input.orgId);
|
|
101
|
+
const r = recordedFrom(ids, input.entryKey);
|
|
102
|
+
if (!r)
|
|
103
|
+
return null;
|
|
104
|
+
return {
|
|
105
|
+
entryKey: input.entryKey,
|
|
106
|
+
live: r.live,
|
|
107
|
+
weights: r.weights,
|
|
108
|
+
digests: r.digests,
|
|
109
|
+
stageDigest: r.stageDigest,
|
|
110
|
+
window: r.window,
|
|
111
|
+
signal: readSignal(ids, input.entryKey),
|
|
112
|
+
held: r.held,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const actionSchema = z.discriminatedUnion('kind', [
|
|
116
|
+
z.object({ kind: z.literal('promote') }),
|
|
117
|
+
z.object({ kind: z.literal('rollback') }),
|
|
118
|
+
z.object({ kind: z.literal('clear_held'), digest: z.string().min(1) }),
|
|
119
|
+
z.object({ kind: z.literal('decide_at_cap'), decision: z.enum(['pass', 'rollback']) }),
|
|
120
|
+
]);
|
|
121
|
+
/** C2: ask the runner to apply an action on its next run. Archon audits it. */
|
|
122
|
+
export async function requestReleaseAction(store, input) {
|
|
123
|
+
releaseKeys(input.entryKey);
|
|
124
|
+
await store.addRequest({ ...input, action: actionSchema.parse(input.action) });
|
|
125
|
+
}
|
|
126
|
+
export { actionSchema as releaseActionSchema };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type Color = 'blue' | 'green';
|
|
2
|
+
export type WindowState = {
|
|
3
|
+
kind: 'idle';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'step';
|
|
6
|
+
percent: number;
|
|
7
|
+
since: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'inconclusive';
|
|
10
|
+
percent: number;
|
|
11
|
+
since: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'window';
|
|
14
|
+
since: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'passed';
|
|
17
|
+
at: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'rolled_back';
|
|
20
|
+
at: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'cap';
|
|
24
|
+
since: string;
|
|
25
|
+
};
|
|
26
|
+
export interface ReleaseSignal {
|
|
27
|
+
requests: number;
|
|
28
|
+
errors: number;
|
|
29
|
+
baselineRate: number | null;
|
|
30
|
+
verdict: 'pass' | 'fail' | 'hard_fail' | 'inconclusive';
|
|
31
|
+
}
|
|
32
|
+
export interface ReleaseState {
|
|
33
|
+
entryKey: string;
|
|
34
|
+
live: Color;
|
|
35
|
+
weights: Record<Color, number>;
|
|
36
|
+
digests: Record<Color, string | null>;
|
|
37
|
+
stageDigest: string | null;
|
|
38
|
+
window: WindowState;
|
|
39
|
+
signal: ReleaseSignal | null;
|
|
40
|
+
held: string[];
|
|
41
|
+
}
|
|
42
|
+
export type ReleaseAction = {
|
|
43
|
+
kind: 'promote';
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'rollback';
|
|
46
|
+
} | {
|
|
47
|
+
kind: 'clear_held';
|
|
48
|
+
digest: string;
|
|
49
|
+
} | {
|
|
50
|
+
kind: 'decide_at_cap';
|
|
51
|
+
decision: 'pass' | 'rollback';
|
|
52
|
+
};
|
|
53
|
+
export declare const COLORS: readonly Color[];
|
|
54
|
+
export declare function otherColor(color: Color): Color;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DeclaredOrg, type MailMode, type OrgId } from './org.js';
|
|
2
2
|
import type { PlanDetail } from './plan/diff.js';
|
|
3
3
|
/**
|
|
4
4
|
* The whitelist of `DeclaredOrg` fields a company's own Boule can request a
|
|
@@ -15,13 +15,22 @@ import type { PlanDetail } from './plan/diff.js';
|
|
|
15
15
|
* lowering or leaving a limit unchanged -- raising either one needs
|
|
16
16
|
* approval too, until a billing field exists to say what the raise costs.
|
|
17
17
|
*/
|
|
18
|
-
export type RequestableField = 'calls' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
18
|
+
export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
19
19
|
export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
|
|
20
20
|
/** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
|
|
21
21
|
export interface CallsChange {
|
|
22
22
|
field: 'calls';
|
|
23
23
|
enabled: boolean;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Turn the org's customers on or off and let them register themselves
|
|
27
|
+
* (`org.ts`'s `CustomersConfig`). `signUp` needs `enabled`: the request is refused otherwise.
|
|
28
|
+
*/
|
|
29
|
+
export interface CustomersChange {
|
|
30
|
+
field: 'customers';
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
signUp: boolean;
|
|
33
|
+
}
|
|
25
34
|
/** Change the org's own spend limit on Files (`org.ts`'s `StorageConfig`). */
|
|
26
35
|
export interface StorageLimitChange {
|
|
27
36
|
field: 'storageLimit';
|
|
@@ -48,7 +57,7 @@ export interface MailTierChange {
|
|
|
48
57
|
* {@link RequestableField}. `classifyChange` and the store both switch on
|
|
49
58
|
* `.field`.
|
|
50
59
|
*/
|
|
51
|
-
export type RequestedChangeValue = CallsChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
|
|
60
|
+
export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
|
|
52
61
|
/**
|
|
53
62
|
* `auto`: nothing further is required -- archon may carry the request
|
|
54
63
|
* straight into a plan and apply it. `approval`: a human must approve the
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { validateCustomers } from './org.js';
|
|
1
2
|
export const REQUESTABLE_FIELDS = [
|
|
2
3
|
'calls',
|
|
4
|
+
'customers',
|
|
3
5
|
'storageLimit',
|
|
4
6
|
'aiLimit',
|
|
5
7
|
'domain',
|
|
@@ -27,6 +29,8 @@ function applyRequestedChange(org, change) {
|
|
|
27
29
|
switch (change.field) {
|
|
28
30
|
case 'calls':
|
|
29
31
|
return { ...org, calls: { ...org.calls, enabled: change.enabled } };
|
|
32
|
+
case 'customers':
|
|
33
|
+
return { ...org, customers: { enabled: change.enabled, signUp: change.signUp } };
|
|
30
34
|
case 'storageLimit':
|
|
31
35
|
return {
|
|
32
36
|
...org,
|
|
@@ -69,6 +73,15 @@ function isLimitRaise(before, after) {
|
|
|
69
73
|
return true;
|
|
70
74
|
return BigInt(after) > BigInt(before);
|
|
71
75
|
}
|
|
76
|
+
/** Throws {@link RequestedChangeError} unless both switches are booleans and sign-up comes with customers on. */
|
|
77
|
+
function validateCustomersChange(change) {
|
|
78
|
+
if (typeof change.enabled !== 'boolean' || typeof change.signUp !== 'boolean') {
|
|
79
|
+
throw new RequestedChangeError('customers needs enabled and signUp, both true or false');
|
|
80
|
+
}
|
|
81
|
+
if (change.signUp && !change.enabled) {
|
|
82
|
+
throw new RequestedChangeError('customers.signUp needs customers.enabled');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
72
85
|
/**
|
|
73
86
|
* `auto` when `change` is small and safe (`calls`, and `storageLimit`/
|
|
74
87
|
* `aiLimit` in the lowering or unchanged direction), the plan it causes has
|
|
@@ -102,6 +115,14 @@ export function classifyChange(org, change, plan) {
|
|
|
102
115
|
if (!REQUESTABLE_FIELDS.includes(change.field)) {
|
|
103
116
|
throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
|
|
104
117
|
}
|
|
118
|
+
if (change.field === 'customers') {
|
|
119
|
+
validateCustomersChange(change);
|
|
120
|
+
// The org after the change must still pass the rule apply checks, for example open registration
|
|
121
|
+
// on a hosting entry needs customers on; otherwise the request is stored and fails at apply.
|
|
122
|
+
const invalid = validateCustomers(applyRequestedChange(org, change));
|
|
123
|
+
if (invalid)
|
|
124
|
+
throw new RequestedChangeError(invalid);
|
|
125
|
+
}
|
|
105
126
|
if (plan?.plannedSteps.some((planned) => planned.step.destroys || planned.step.migrates)) {
|
|
106
127
|
return 'approval';
|
|
107
128
|
}
|
|
@@ -110,6 +131,7 @@ export function classifyChange(org, change, plan) {
|
|
|
110
131
|
return 'approval';
|
|
111
132
|
switch (change.field) {
|
|
112
133
|
case 'calls':
|
|
134
|
+
case 'customers':
|
|
113
135
|
return 'auto';
|
|
114
136
|
case 'storageLimit':
|
|
115
137
|
return isLimitRaise(org.storage.limitBytes, change.limitBytes) ||
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
import { type ReleaseStore } from '../release/state.js';
|
|
2
3
|
import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
|
|
3
4
|
/**
|
|
4
5
|
* The host's own drizzle handle or a transaction open on it, same shape
|
|
@@ -98,3 +99,10 @@ export declare function createPostgresRequestedChangeStore(db: FoundryStoreDb):
|
|
|
98
99
|
* lost the way `foundry_applied_state`'s one row per service would lose it.
|
|
99
100
|
*/
|
|
100
101
|
export declare function createPostgresAppliedVersionsStore(db: FoundryStoreDb): AppliedVersionsStore;
|
|
102
|
+
/**
|
|
103
|
+
* `ReleaseStore` (`../release/state.ts`) on Postgres: the recorded `bg.*`
|
|
104
|
+
* keys come from an org's applied state, or the platform's for the orgId
|
|
105
|
+
* `platform`; requests are rows in `foundry_release_requests`. Reading leaves
|
|
106
|
+
* the open rows consumed only when the caller says its run succeeded (`consumeRequests`).
|
|
107
|
+
*/
|
|
108
|
+
export declare function createPostgresReleaseStore(db: FoundryStoreDb): ReleaseStore;
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { and, asc, desc, eq, ne, sql } from 'drizzle-orm';
|
|
2
|
-
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
|
|
3
|
-
import {
|
|
1
|
+
import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
2
|
+
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBlueGreen, validateBuyerIdentity, validateCalls, validateCustomers, validateDomain, validateDomainClaim, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
|
|
3
|
+
import { hostingNames } from '../plan/locked-hostnames.js';
|
|
4
4
|
import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
|
|
5
5
|
import { validatePlatform } from '../platform.js';
|
|
6
|
+
import { releaseActionSchema } from '../release/state.js';
|
|
6
7
|
import { RequestedChangeError, classifyChange } from '../requested-changes.js';
|
|
7
8
|
import { validateSlug } from '../slug.js';
|
|
8
9
|
import { StaleLeaseError, } from '../store.js';
|
|
9
|
-
import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryRequestedChanges, } from './schema.js';
|
|
10
|
+
import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryReleaseRequests, foundryRequestedChanges, } from './schema.js';
|
|
10
11
|
const UNIQUE_VIOLATION = '23505';
|
|
11
12
|
/**
|
|
12
13
|
* #441: runs `write`, and with a `fence` only if its token is still the
|
|
@@ -148,6 +149,10 @@ function rowToOrg(row) {
|
|
|
148
149
|
: {}),
|
|
149
150
|
// Omitted while off.
|
|
150
151
|
...(row.signInInApp ? { signIn: { inApp: true } } : {}),
|
|
152
|
+
// Omitted while off.
|
|
153
|
+
...(row.customersEnabled || row.customersSignUp
|
|
154
|
+
? { customers: { enabled: row.customersEnabled, signUp: row.customersSignUp } }
|
|
155
|
+
: {}),
|
|
151
156
|
// Omitted while unset. Validated on read: a stored value is not trusted.
|
|
152
157
|
...(row.versionPin ? { versionPin: readVersionPin(row) } : {}),
|
|
153
158
|
site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
|
|
@@ -201,6 +206,8 @@ function orgToRow(org) {
|
|
|
201
206
|
callsVersionPinMas: org.calls.versionPin?.mas ?? null,
|
|
202
207
|
issuerUsesDomain: org.issuer.usesDomain,
|
|
203
208
|
signInInApp: org.signIn?.inApp ?? false,
|
|
209
|
+
customersEnabled: org.customers?.enabled ?? false,
|
|
210
|
+
customersSignUp: org.customers?.signUp ?? false,
|
|
204
211
|
servicesOwn: org.services?.own ?? false,
|
|
205
212
|
servicesSfuSlot: org.services?.sfuSlot ?? null,
|
|
206
213
|
versionPin: org.versionPin && Object.keys(org.versionPin).length > 0 ? org.versionPin : null,
|
|
@@ -350,10 +357,7 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
350
357
|
// #71: same place `requireValidBuyer` checks the buyer identity (#67), on
|
|
351
358
|
// both `create` and the merged result of `update`.
|
|
352
359
|
function requireValidHosting(org) {
|
|
353
|
-
const reason = validateHosting(org.hosting,
|
|
354
|
-
domain: org.domain,
|
|
355
|
-
locked: lockedHostnames(org, []),
|
|
356
|
-
});
|
|
360
|
+
const reason = validateHosting(org.hosting, hostingNames(org, []));
|
|
357
361
|
if (reason)
|
|
358
362
|
throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
|
|
359
363
|
}
|
|
@@ -407,6 +411,9 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
407
411
|
const signInReason = validateSignIn(org);
|
|
408
412
|
if (signInReason)
|
|
409
413
|
throw new DeclaredOrgError(`signIn invalid: ${signInReason}`);
|
|
414
|
+
const customersReason = validateCustomers(org);
|
|
415
|
+
if (customersReason)
|
|
416
|
+
throw new DeclaredOrgError(`customers invalid: ${customersReason}`);
|
|
410
417
|
const slot = org.services?.sfuSlot ?? null;
|
|
411
418
|
if (slot === null)
|
|
412
419
|
return;
|
|
@@ -718,6 +725,10 @@ function rowToPlatform(row) {
|
|
|
718
725
|
enabled: row.archonEnabled,
|
|
719
726
|
hostname: row.archonHostname,
|
|
720
727
|
gitRef: row.archonGitRef,
|
|
728
|
+
// Omitted while off. Validated on read: a stored value is not trusted.
|
|
729
|
+
...(row.archonBlueGreen && validateBlueGreen(row.archonBlueGreen, 'archon') === null
|
|
730
|
+
? { blueGreen: row.archonBlueGreen }
|
|
731
|
+
: {}),
|
|
721
732
|
},
|
|
722
733
|
edge: {
|
|
723
734
|
enabled: row.edgeEnabled,
|
|
@@ -771,6 +782,7 @@ function platformToRow(platform) {
|
|
|
771
782
|
archonEnabled: platform.archon.enabled,
|
|
772
783
|
archonHostname: platform.archon.hostname,
|
|
773
784
|
archonGitRef: platform.archon.gitRef,
|
|
785
|
+
archonBlueGreen: platform.archon.blueGreen ?? null,
|
|
774
786
|
edgeEnabled: platform.edge.enabled,
|
|
775
787
|
edgeHostname: platform.edge.hostname,
|
|
776
788
|
edgeGitRef: platform.edge.gitRef,
|
|
@@ -923,3 +935,50 @@ export function createPostgresAppliedVersionsStore(db) {
|
|
|
923
935
|
},
|
|
924
936
|
};
|
|
925
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* `ReleaseStore` (`../release/state.ts`) on Postgres: the recorded `bg.*`
|
|
940
|
+
* keys come from an org's applied state, or the platform's for the orgId
|
|
941
|
+
* `platform`; requests are rows in `foundry_release_requests`. Reading leaves
|
|
942
|
+
* the open rows consumed only when the caller says its run succeeded (`consumeRequests`).
|
|
943
|
+
*/
|
|
944
|
+
export function createPostgresReleaseStore(db) {
|
|
945
|
+
return {
|
|
946
|
+
async getScopeIds(orgId) {
|
|
947
|
+
if (orgId === 'platform') {
|
|
948
|
+
const [row] = await db
|
|
949
|
+
.select({ ids: foundryDeclaredPlatform.serviceIds })
|
|
950
|
+
.from(foundryDeclaredPlatform)
|
|
951
|
+
.limit(1);
|
|
952
|
+
return row?.ids ?? {};
|
|
953
|
+
}
|
|
954
|
+
const [row] = await db
|
|
955
|
+
.select({ ids: foundryAppliedState.serviceIds })
|
|
956
|
+
.from(foundryAppliedState)
|
|
957
|
+
.where(eq(foundryAppliedState.orgId, orgId))
|
|
958
|
+
.limit(1);
|
|
959
|
+
return row?.ids ?? {};
|
|
960
|
+
},
|
|
961
|
+
async addRequest({ orgId, entryKey, action, actor }) {
|
|
962
|
+
await db.insert(foundryReleaseRequests).values({ orgId, entryKey, action, actor });
|
|
963
|
+
},
|
|
964
|
+
async openRequests({ orgId, entryKey }) {
|
|
965
|
+
const rows = await db
|
|
966
|
+
.select()
|
|
967
|
+
.from(foundryReleaseRequests)
|
|
968
|
+
.where(and(eq(foundryReleaseRequests.orgId, orgId), eq(foundryReleaseRequests.entryKey, entryKey), isNull(foundryReleaseRequests.consumedAt)))
|
|
969
|
+
.orderBy(asc(foundryReleaseRequests.id));
|
|
970
|
+
return rows.flatMap((row) => {
|
|
971
|
+
const action = releaseActionSchema.safeParse(row.action);
|
|
972
|
+
return action.success ? [{ id: row.id, action: action.data, actor: row.actor }] : [];
|
|
973
|
+
});
|
|
974
|
+
},
|
|
975
|
+
async consumeRequests(ids) {
|
|
976
|
+
if (ids.length === 0)
|
|
977
|
+
return;
|
|
978
|
+
await db
|
|
979
|
+
.update(foundryReleaseRequests)
|
|
980
|
+
.set({ consumedAt: sql `now()` })
|
|
981
|
+
.where(and(inArray(foundryReleaseRequests.id, [...ids]), isNull(foundryReleaseRequests.consumedAt)));
|
|
982
|
+
},
|
|
983
|
+
};
|
|
984
|
+
}
|