@wtfalch/foundry 0.46.0 → 0.48.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/build-json.d.ts +28 -0
- package/dist/build-json.js +95 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -2
- package/dist/migrations/0058_app_requests.sql +32 -0
- package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
- package/dist/org.d.ts +47 -5
- package/dist/org.js +176 -128
- package/dist/plan/archon-blue-green.js +15 -1
- package/dist/plan/blue-green-apply.d.ts +35 -0
- package/dist/plan/blue-green-apply.js +167 -16
- package/dist/plan/blue-green.d.ts +97 -2
- package/dist/plan/blue-green.js +214 -13
- package/dist/plan/database-dump.d.ts +37 -0
- package/dist/plan/database-dump.js +101 -0
- package/dist/plan/diff.js +9 -5
- package/dist/plan/executor.d.ts +48 -0
- package/dist/plan/executor.js +87 -28
- package/dist/plan/health-targets.js +1 -2
- package/dist/plan/hostnames.d.ts +2 -2
- package/dist/plan/hostnames.js +6 -6
- package/dist/plan/index.d.ts +3 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/locked-hostnames.d.ts +1 -0
- package/dist/plan/locked-hostnames.js +1 -0
- package/dist/plan/plan-apply.d.ts +30 -1
- package/dist/plan/plan-apply.js +86 -8
- package/dist/plan/platform-diff.js +2 -1
- package/dist/plan/platform-providers.js +5 -0
- package/dist/plan/providers-readers.js +15 -4
- package/dist/plan/proxy-labels.d.ts +13 -1
- package/dist/plan/proxy-labels.js +26 -3
- package/dist/plan/release-host.d.ts +1 -1
- package/dist/plan/release-host.js +36 -14
- package/dist/plan/stage-env.d.ts +38 -0
- package/dist/plan/stage-env.js +52 -0
- package/dist/plan/stage-guards.d.ts +104 -0
- package/dist/plan/stage-guards.js +149 -0
- package/dist/plan/stage-isolation.d.ts +57 -0
- package/dist/plan/stage-isolation.js +248 -0
- package/dist/plan/worker-app.js +3 -1
- package/dist/refuse-org.d.ts +7 -0
- package/dist/refuse-org.js +12 -0
- package/dist/release/decide.d.ts +5 -0
- package/dist/release/decide.js +10 -6
- package/dist/release/health.d.ts +6 -1
- package/dist/release/health.js +10 -2
- package/dist/release/run.d.ts +2 -0
- package/dist/release/run.js +2 -1
- package/dist/requested-changes.d.ts +50 -4
- package/dist/requested-changes.js +9 -1
- package/dist/store/postgres.d.ts +5 -1
- package/dist/store/postgres.js +86 -8
- package/dist/store/schema.d.ts +111 -1
- package/dist/store/schema.js +7 -0
- package/dist/store.d.ts +24 -1
- package/dist/worker-app-config.d.ts +8 -2
- package/dist/worker-app-config.js +20 -6
- package/package.json +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const SECRET_NAME = /(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|KEK|DATABASE_URL)/;
|
|
3
|
+
const URL_NAME = /(_URL|_URI|_UPSTREAM|_ISSUER)$/;
|
|
4
|
+
const ID_NAME = /(_ID|_ORGANI[SZ]ATION_ID)$/;
|
|
5
|
+
/** The class of an env name, from its spelling. */
|
|
6
|
+
export function envClass(name) {
|
|
7
|
+
if (SECRET_NAME.test(name))
|
|
8
|
+
return 'secret';
|
|
9
|
+
if (URL_NAME.test(name))
|
|
10
|
+
return 'url';
|
|
11
|
+
if (ID_NAME.test(name))
|
|
12
|
+
return 'id';
|
|
13
|
+
return 'literal';
|
|
14
|
+
}
|
|
15
|
+
/** `AB_*` matches by prefix; anything else matches exactly. */
|
|
16
|
+
function globMatches(pattern, name) {
|
|
17
|
+
return pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : pattern === name;
|
|
18
|
+
}
|
|
19
|
+
const matchesAny = (patterns, name) => patterns.some((p) => globMatches(p, name));
|
|
20
|
+
const MAIL_NAME = /^(MAIL_|LETTERMINT_|SMTP_)/;
|
|
21
|
+
/** Env names (or `PREFIX_*`) each kind of stage app may hold. A name not here is refused. */
|
|
22
|
+
export const STAGE_ALLOWLIST = {
|
|
23
|
+
boule: [
|
|
24
|
+
'APP_URL',
|
|
25
|
+
'BOULE_ORG_NAME',
|
|
26
|
+
'AUTH_ISSUER',
|
|
27
|
+
'AUTH_ORGANIZATION_ID',
|
|
28
|
+
'AUTH_CLIENT_ID',
|
|
29
|
+
'AUTH_APP_KEY',
|
|
30
|
+
'AUTH_COOKIE_SECRET',
|
|
31
|
+
'DATABASE_URL',
|
|
32
|
+
'DATABASE_URL_OWNER',
|
|
33
|
+
'LOG_DIR',
|
|
34
|
+
'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY',
|
|
35
|
+
'REPORTING_*',
|
|
36
|
+
'AUTHZ_BOOTSTRAP_OPERATOR*',
|
|
37
|
+
'AUTHZ_FIRST_OWNER*',
|
|
38
|
+
'CUSTOMERS_ORGANISATION_ID',
|
|
39
|
+
'ARCHON_URL',
|
|
40
|
+
'ARCHON_ORG_ID',
|
|
41
|
+
'ARCHON_CREDENTIAL',
|
|
42
|
+
'ARCHON_BRIDGE_SECRET',
|
|
43
|
+
'AUTHZ_PEER_PASSWORD_OPERATOR',
|
|
44
|
+
'OWNER_SEED_SECRET',
|
|
45
|
+
],
|
|
46
|
+
agora: ['AGORA_CONFIG', 'SIGNIN_UPSTREAM', 'LOG_DIR'],
|
|
47
|
+
archon: [
|
|
48
|
+
'APP_URL',
|
|
49
|
+
'AUTH_ISSUER',
|
|
50
|
+
'AUTH_ORGANIZATION_ID',
|
|
51
|
+
'CI_ORGANIZATION_ID',
|
|
52
|
+
'AUTH_CLIENT_ID',
|
|
53
|
+
'AUTH_APP_KEY',
|
|
54
|
+
'AUTH_COOKIE_SECRET',
|
|
55
|
+
'DATABASE_URL',
|
|
56
|
+
'DATABASE_URL_OWNER',
|
|
57
|
+
'AUTHZ_DATABASE_URL',
|
|
58
|
+
'BOULE_URL',
|
|
59
|
+
'ARCHON_BRIDGE_SECRET',
|
|
60
|
+
'REPORTING_SITE',
|
|
61
|
+
'KEYSTORE_KEK',
|
|
62
|
+
'LOG_LEVEL',
|
|
63
|
+
'LOG_DIR',
|
|
64
|
+
'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY',
|
|
65
|
+
],
|
|
66
|
+
customer: [
|
|
67
|
+
'LOG_DIR',
|
|
68
|
+
'NEXT_SERVER_ACTIONS_ENCRYPTION_KEY',
|
|
69
|
+
'APP_URL',
|
|
70
|
+
'APP_ENVIRONMENT',
|
|
71
|
+
'DATABASE_URL',
|
|
72
|
+
'DATABASE_URL_OWNER',
|
|
73
|
+
'AUTH_ISSUER',
|
|
74
|
+
'AUTH_ORGANIZATION_ID',
|
|
75
|
+
'AUTH_CLIENT_ID',
|
|
76
|
+
'AUTH_APP_KEY',
|
|
77
|
+
'AUTH_COOKIE_SECRET',
|
|
78
|
+
'FILES_URL',
|
|
79
|
+
'FILES_ORGANISATION_ID',
|
|
80
|
+
'FILES_API_KEY',
|
|
81
|
+
'AI_URL',
|
|
82
|
+
'AI_ORGANISATION_ID',
|
|
83
|
+
'AI_API_KEY',
|
|
84
|
+
'AI_WEBHOOK_SECRET',
|
|
85
|
+
'JOBS_SCHEDULER_URL',
|
|
86
|
+
'JOBS_SIGNING_SECRET',
|
|
87
|
+
'JOBS_SYNC_KEY',
|
|
88
|
+
'ANALYTICS_URL',
|
|
89
|
+
'ANALYTICS_SITE_KEY',
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
/** Names no stage holds, whatever the app. */
|
|
93
|
+
const DENY_COMMON = [
|
|
94
|
+
'LETTERMINT_API_TOKEN',
|
|
95
|
+
'MAIL_ADMIN_*',
|
|
96
|
+
'MAIL_SMTP_*',
|
|
97
|
+
'OFFBOARDING_SERVICES',
|
|
98
|
+
'CHAT_ADMIN_INSTANCES',
|
|
99
|
+
'COOLIFY_*',
|
|
100
|
+
'CF_*',
|
|
101
|
+
'CLOUDFLARE_*',
|
|
102
|
+
'ZITADEL_*',
|
|
103
|
+
'TUNNEL_TOKEN',
|
|
104
|
+
'CUSTOMERS_DIRECTORY_CREDENTIAL',
|
|
105
|
+
'FOUNDRY_*',
|
|
106
|
+
'INTEGRATIONS_*',
|
|
107
|
+
'KEYSTORE_KEK_PREVIOUS',
|
|
108
|
+
'UPTIME_*',
|
|
109
|
+
'JOBS_ADMIN_TOKEN',
|
|
110
|
+
'CUSTOMER_GRANTS',
|
|
111
|
+
'DEPLOYMENT_CAPABILITIES',
|
|
112
|
+
'CHARGE_TOKEN',
|
|
113
|
+
'USAGE_TOKEN',
|
|
114
|
+
'RELEASE_TICK_TOKEN',
|
|
115
|
+
'ARCHON_SETUP_CODE',
|
|
116
|
+
'ARCHON_FIRST_OWNER_LINK',
|
|
117
|
+
'MAINTENANCE_BYPASS_TOKEN',
|
|
118
|
+
];
|
|
119
|
+
const DENY_BY_KIND = {
|
|
120
|
+
boule: ['JOBS_*', 'EDGE_*'],
|
|
121
|
+
agora: [
|
|
122
|
+
'AGORA_*_UPSTREAM',
|
|
123
|
+
'AGORA_MAIL_UPSTREAM',
|
|
124
|
+
'AGORA_FILES_UPSTREAM',
|
|
125
|
+
'AGORA_CHAT_UPSTREAM',
|
|
126
|
+
'AGORA_MATRIX_UPSTREAM',
|
|
127
|
+
'AGORA_MAS_UPSTREAM',
|
|
128
|
+
'AGORA_CALLS_UPSTREAM',
|
|
129
|
+
'MESSAGING_*',
|
|
130
|
+
'MAIL_PROJECT_ID',
|
|
131
|
+
'FILES_PROJECT_ID',
|
|
132
|
+
'AGORA_UPDATE_FEED_URL',
|
|
133
|
+
],
|
|
134
|
+
archon: ['FILES_*', 'AI_*', 'JOBS_*', 'KEYS_*', 'EDGE_*'],
|
|
135
|
+
customer: ['EDGE_*', 'KEYS_*'],
|
|
136
|
+
};
|
|
137
|
+
/** Whether `name` is on the deny list of `kind`. */
|
|
138
|
+
export function isDenied(kind, name) {
|
|
139
|
+
return matchesAny(DENY_COMMON, name) || matchesAny(DENY_BY_KIND[kind], name);
|
|
140
|
+
}
|
|
141
|
+
/** Whether `name` is on the allowlist of `kind`. Mail names are checked by A5 only. */
|
|
142
|
+
export function isAllowed(kind, name) {
|
|
143
|
+
return matchesAny(STAGE_ALLOWLIST[kind], name);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Env that stage holds with the same value as production, on purpose. Each entry says why that cannot write
|
|
147
|
+
* to production. A name not here must differ from every production value.
|
|
148
|
+
*/
|
|
149
|
+
export const SHARED_WITH_PROD = {
|
|
150
|
+
AUTH_ISSUER: 'stage signs in through the same issuer by decision; it holds no ZITADEL management token, and an OIDC client cannot write users',
|
|
151
|
+
AUTH_ORGANIZATION_ID: 'same issuer organisation as AUTH_ISSUER; an id, not a credential',
|
|
152
|
+
CI_ORGANIZATION_ID: 'an id of the same issuer organisation; not a credential',
|
|
153
|
+
CUSTOMERS_ORGANISATION_ID: 'an id of the customers organisation in the same issuer; not a credential',
|
|
154
|
+
BOULE_ORG_NAME: 'a display name',
|
|
155
|
+
ARCHON_ORG_ID: 'the exact copy of the database holds the same organisation rows',
|
|
156
|
+
'AUTHZ_BOOTSTRAP_OPERATOR*': 'who may operate stage; an address, not a credential',
|
|
157
|
+
'AUTHZ_FIRST_OWNER*': 'who owns stage; an address, not a credential',
|
|
158
|
+
FILES_URL: 'the files service is shared; stage reaches it only with its own organisation and key',
|
|
159
|
+
AI_URL: 'the ai service is shared; stage reaches it only with its own organisation and key',
|
|
160
|
+
JOBS_SCHEDULER_URL: 'the jobs service is shared; stage reaches it only with its own project and signing secret',
|
|
161
|
+
ANALYTICS_URL: 'the analytics collector is shared; stage reports under its own site key',
|
|
162
|
+
};
|
|
163
|
+
/** Whether `name` may equal production's value. */
|
|
164
|
+
export const isSharedWithProd = (name) => Object.keys(SHARED_WITH_PROD).some((p) => globMatches(p, name));
|
|
165
|
+
const LIVE_MODE_KEY = /(^|[^a-z0-9])(sk|rk)_live_|^live_/;
|
|
166
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
167
|
+
const URL_HOST = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?([^/?#:]+)/i;
|
|
168
|
+
const hostOf = (value) => URL_HOST.exec(value)?.[1]?.toLowerCase() ?? null;
|
|
169
|
+
/**
|
|
170
|
+
* The violations of stage isolation, as `<code>:<name>`; empty when stage is isolated. Names only: an env
|
|
171
|
+
* name or a resource kind, never a value.
|
|
172
|
+
* - A1 allowlist: `env_not_allowed`. A2 deny list: `env_denied`.
|
|
173
|
+
* - A3 values: `value_shared` (a secret, url or id equals a production value), `shared_differs` (a name on
|
|
174
|
+
* {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
|
|
175
|
+
* - A4 names: `resource_shared`, `host_is_prod_internal`.
|
|
176
|
+
* - A5 mail: `mail_not_sink`, `mail_token`.
|
|
177
|
+
*/
|
|
178
|
+
export function assertStageIsolated(input) {
|
|
179
|
+
const { kind, prod, stage } = input;
|
|
180
|
+
const sink = input.sinkMail ?? {};
|
|
181
|
+
const out = new Set();
|
|
182
|
+
const prodByName = new Map();
|
|
183
|
+
for (const item of prod.env)
|
|
184
|
+
prodByName.set(item.name, item);
|
|
185
|
+
const prodHashes = new Set();
|
|
186
|
+
const prodRefs = new Set();
|
|
187
|
+
const prodHosts = new Set((prod.internalHosts ?? []).map((h) => h.toLowerCase()));
|
|
188
|
+
for (const item of prod.env) {
|
|
189
|
+
if (item.class === 'literal')
|
|
190
|
+
continue;
|
|
191
|
+
if (item.value) {
|
|
192
|
+
prodHashes.add(sha256(item.value));
|
|
193
|
+
const host = hostOf(item.value);
|
|
194
|
+
if (host)
|
|
195
|
+
prodHosts.add(host);
|
|
196
|
+
}
|
|
197
|
+
if (item.ref)
|
|
198
|
+
prodRefs.add(item.ref);
|
|
199
|
+
}
|
|
200
|
+
for (const item of stage.env) {
|
|
201
|
+
const { name } = item;
|
|
202
|
+
const isMail = MAIL_NAME.test(name);
|
|
203
|
+
if (isMail) {
|
|
204
|
+
if (item.value !== sink[name]) {
|
|
205
|
+
out.add(`${name === 'LETTERMINT_API_TOKEN' ? 'mail_token' : 'mail_not_sink'}:${name}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (isDenied(kind, name))
|
|
209
|
+
out.add(`env_denied:${name}`);
|
|
210
|
+
else if (!isMail && !isAllowed(kind, name))
|
|
211
|
+
out.add(`env_not_allowed:${name}`);
|
|
212
|
+
if (item.value !== undefined && LIVE_MODE_KEY.test(item.value))
|
|
213
|
+
out.add(`live_mode_key:${name}`);
|
|
214
|
+
if (item.class === 'literal')
|
|
215
|
+
continue;
|
|
216
|
+
const shared = isSharedWithProd(name);
|
|
217
|
+
const counterpart = prodByName.get(name);
|
|
218
|
+
if (shared) {
|
|
219
|
+
const same = item.value !== undefined && counterpart?.value !== undefined
|
|
220
|
+
? item.value === counterpart.value
|
|
221
|
+
: item.ref === counterpart?.ref;
|
|
222
|
+
if (counterpart && !same)
|
|
223
|
+
out.add(`shared_differs:${name}`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (item.value && prodHashes.has(sha256(item.value)))
|
|
227
|
+
out.add(`value_shared:${name}`);
|
|
228
|
+
if (item.ref && prodRefs.has(item.ref))
|
|
229
|
+
out.add(`value_shared:${name}`);
|
|
230
|
+
const host = item.value ? hostOf(item.value) : null;
|
|
231
|
+
if (host && prodHosts.has(host))
|
|
232
|
+
out.add(`host_is_prod_internal:${name}`);
|
|
233
|
+
}
|
|
234
|
+
const prodResources = new Set(Object.values(prod.resources));
|
|
235
|
+
for (const [resourceKind, value] of Object.entries(stage.resources)) {
|
|
236
|
+
if (prodResources.has(value))
|
|
237
|
+
out.add(`resource_shared:${resourceKind}`);
|
|
238
|
+
}
|
|
239
|
+
return [...out].sort();
|
|
240
|
+
}
|
|
241
|
+
/** The sha256 of the stage env name set plus the resource set: what `bg.<id>.stageisolation` records. */
|
|
242
|
+
export function stageIsolationHash(stage) {
|
|
243
|
+
const names = stage.env.map((e) => e.name).sort();
|
|
244
|
+
const resources = Object.entries(stage.resources)
|
|
245
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
246
|
+
.sort();
|
|
247
|
+
return sha256(JSON.stringify({ names, resources }));
|
|
248
|
+
}
|
package/dist/plan/worker-app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHostId, } from '../worker-app-config.js';
|
|
2
|
+
import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
|
|
3
3
|
import { entryAuthEnv } from './broker-entries.js';
|
|
4
4
|
import { workerAppHostname } from './hostnames.js';
|
|
5
5
|
import { appStepKey } from './platform-diff.js';
|
|
@@ -315,6 +315,8 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
315
315
|
description: `${at} is blocked, not planned: ${why}`,
|
|
316
316
|
key,
|
|
317
317
|
});
|
|
318
|
+
if (!workerAppHasBuild(app))
|
|
319
|
+
continue;
|
|
318
320
|
if (!input.available) {
|
|
319
321
|
block('the host supplies no WorkerAppPort');
|
|
320
322
|
continue;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DeclaredOrg, type OrgRefusal } from './org.js';
|
|
2
|
+
/**
|
|
3
|
+
* The first refusal of the whole-company validation a store write runs for
|
|
4
|
+
* hosting (every entry, its sign-in included), customers and the blue/green of Boule and Agora, or `null`. Locked
|
|
5
|
+
* names are the org's own; the platform's service names are not known here.
|
|
6
|
+
*/
|
|
7
|
+
export declare function refuseOrg(org: DeclaredOrg): OrgRefusal | null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, } from './org.js';
|
|
2
|
+
import { hostingNames } from './plan/locked-hostnames.js';
|
|
3
|
+
/**
|
|
4
|
+
* The first refusal of the whole-company validation a store write runs for
|
|
5
|
+
* hosting (every entry, its sign-in included), customers and the blue/green of Boule and Agora, or `null`. Locked
|
|
6
|
+
* names are the org's own; the platform's service names are not known here.
|
|
7
|
+
*/
|
|
8
|
+
export function refuseOrg(org) {
|
|
9
|
+
return (refuseHosting(org.hosting, hostingNames(org, [])) ??
|
|
10
|
+
refuseCustomers(org) ??
|
|
11
|
+
refuseBuiltinBlueGreen(org));
|
|
12
|
+
}
|
package/dist/release/decide.d.ts
CHANGED
|
@@ -92,4 +92,9 @@ export declare function decide(input: {
|
|
|
92
92
|
stepHoldMinutes?: number | undefined;
|
|
93
93
|
now: Date;
|
|
94
94
|
observation: Observation;
|
|
95
|
+
/**
|
|
96
|
+
* The app has no reporting health read (a static app). Only hard fails count: it never reports a relative
|
|
97
|
+
* fail, steps up by time, and the window closes after its minimum time.
|
|
98
|
+
*/
|
|
99
|
+
hardOnly?: boolean;
|
|
95
100
|
}): Decision;
|
package/dist/release/decide.js
CHANGED
|
@@ -150,7 +150,7 @@ export function decide(input) {
|
|
|
150
150
|
r = rollback(r, now, 'deploy timeout');
|
|
151
151
|
}
|
|
152
152
|
else {
|
|
153
|
-
const health = observation.health;
|
|
153
|
+
const health = input.hardOnly ? null : observation.health;
|
|
154
154
|
const newC = health?.colors[rel.to] ?? { requests: 0, errors: 0 };
|
|
155
155
|
const atWindow = r.window.kind === 'window' || r.window.kind === 'cap';
|
|
156
156
|
let baseline = null;
|
|
@@ -162,6 +162,8 @@ export function decide(input) {
|
|
|
162
162
|
let verdict;
|
|
163
163
|
if (hard || hardRate)
|
|
164
164
|
verdict = 'hard_fail';
|
|
165
|
+
else if (input.hardOnly)
|
|
166
|
+
verdict = 'pass';
|
|
165
167
|
else if (!health || baseline === null || newC.requests < RELATIVE_MIN_REQUESTS)
|
|
166
168
|
verdict = 'inconclusive';
|
|
167
169
|
else
|
|
@@ -200,11 +202,13 @@ export function decide(input) {
|
|
|
200
202
|
}
|
|
201
203
|
else {
|
|
202
204
|
const required = Math.max(1, Math.ceil(health?.daily.requestsPerDay ?? 0));
|
|
203
|
-
const closable =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
const closable = input.hardOnly
|
|
206
|
+
? elapsed >= WINDOW_MIN_HOURS * HOUR
|
|
207
|
+
: health !== null &&
|
|
208
|
+
baseline !== null &&
|
|
209
|
+
elapsed >= WINDOW_MIN_HOURS * HOUR &&
|
|
210
|
+
newC.requests >= required &&
|
|
211
|
+
rate(newC) <= baseline + ERROR_RATE_MARGIN;
|
|
208
212
|
if (closable) {
|
|
209
213
|
out.effects.push({ kind: 'stop', color: rel.from });
|
|
210
214
|
r = { ...r, window: { kind: 'passed', at: now.toISOString() }, release: null };
|
package/dist/release/health.d.ts
CHANGED
|
@@ -39,10 +39,15 @@ export declare function readReleaseHealth(input: {
|
|
|
39
39
|
headers?: Record<string, string>;
|
|
40
40
|
fetchImpl?: typeof fetch;
|
|
41
41
|
}): Promise<HealthRead | null>;
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* An HTTP check of the color's health path on its own address. A redirect is a failure: it is how a login
|
|
44
|
+
* wall (Cloudflare Access) answers a machine. With `expectJson` the body must also parse as JSON, so a login page served with 200 is not read as a healthy color.
|
|
45
|
+
*/
|
|
43
46
|
export declare function checkColorAddress(input: {
|
|
44
47
|
address: string;
|
|
45
48
|
healthPath: string | null;
|
|
46
49
|
headers?: Record<string, string>;
|
|
50
|
+
/** True for an app whose health path answers JSON; false for a static app's `/`. */
|
|
51
|
+
expectJson?: boolean;
|
|
47
52
|
fetchImpl?: typeof fetch;
|
|
48
53
|
}): Promise<'ok' | 'failed'>;
|
package/dist/release/health.js
CHANGED
|
@@ -45,14 +45,22 @@ export async function readReleaseHealth(input) {
|
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* An HTTP check of the color's health path on its own address. A redirect is a failure: it is how a login
|
|
50
|
+
* wall (Cloudflare Access) answers a machine. With `expectJson` the body must also parse as JSON, so a login page served with 200 is not read as a healthy color.
|
|
51
|
+
*/
|
|
49
52
|
export async function checkColorAddress(input) {
|
|
50
53
|
try {
|
|
51
54
|
const res = await (input.fetchImpl ?? fetch)(new URL(input.healthPath ?? '/', input.address), {
|
|
52
55
|
...(input.headers ? { headers: input.headers } : {}),
|
|
56
|
+
redirect: 'manual',
|
|
53
57
|
signal: AbortSignal.timeout(ADDRESS_CHECK_TIMEOUT_MS),
|
|
54
58
|
});
|
|
55
|
-
|
|
59
|
+
if (!res.ok)
|
|
60
|
+
return 'failed';
|
|
61
|
+
if (input.expectJson)
|
|
62
|
+
await res.json();
|
|
63
|
+
return 'ok';
|
|
56
64
|
}
|
|
57
65
|
catch {
|
|
58
66
|
// Unreachable counts as a failed check.
|
package/dist/release/run.d.ts
CHANGED
package/dist/release/run.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function runRelease(input) {
|
|
|
16
16
|
const actions = open.map((r) => r.action);
|
|
17
17
|
const range = healthRange(recorded, now);
|
|
18
18
|
const target = recorded.release?.to ?? null;
|
|
19
|
-
const health = range ? await deps.readHealth(range) : null;
|
|
19
|
+
const health = range && !input.hardOnly ? await deps.readHealth(range) : null;
|
|
20
20
|
const seen = target
|
|
21
21
|
? await deps.observeColor(target)
|
|
22
22
|
: { status: 'other', address: 'unchecked' };
|
|
@@ -27,6 +27,7 @@ export async function runRelease(input) {
|
|
|
27
27
|
stepHoldMinutes: input.entry.stepHoldMinutes,
|
|
28
28
|
now,
|
|
29
29
|
observation: { health, ...seen },
|
|
30
|
+
...(input.hardOnly ? { hardOnly: true } : {}),
|
|
30
31
|
});
|
|
31
32
|
for (const effect of decision.effects)
|
|
32
33
|
await deps.effect(effect);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { BuildJson } from './build-json.js';
|
|
2
|
+
import { type CustomersConfig, type DeclaredOrg, type HostingEntry, type MailMode, type OrgId } from './org.js';
|
|
2
3
|
import type { PlanDetail } from './plan/diff.js';
|
|
3
4
|
/**
|
|
4
5
|
* The whitelist of `DeclaredOrg` fields a company's own Boule can request a
|
|
@@ -15,7 +16,7 @@ import type { PlanDetail } from './plan/diff.js';
|
|
|
15
16
|
* lowering or leaving a limit unchanged -- raising either one needs
|
|
16
17
|
* approval too, until a billing field exists to say what the raise costs.
|
|
17
18
|
*/
|
|
18
|
-
export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
19
|
+
export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier' | 'app' | 'build';
|
|
19
20
|
export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
|
|
20
21
|
/** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
|
|
21
22
|
export interface CallsChange {
|
|
@@ -52,12 +53,34 @@ export interface MailTierChange {
|
|
|
52
53
|
field: 'mailTier';
|
|
53
54
|
mode: MailMode;
|
|
54
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Put or remove one hosting entry of the company's own app (`org.ts`'s `HostingEntry`).
|
|
58
|
+
* `before` and `after` are the entry as the requester saw it and as it should be (null when absent).
|
|
59
|
+
* `customers` is the switches after the change when the request changes them, else null.
|
|
60
|
+
* Archon validates the whole company; {@link classifyChange} does not look at the org for this kind.
|
|
61
|
+
*/
|
|
62
|
+
export interface AppChange {
|
|
63
|
+
field: 'app';
|
|
64
|
+
key: string;
|
|
65
|
+
op: 'put' | 'remove';
|
|
66
|
+
before: HostingEntry | null;
|
|
67
|
+
after: HostingEntry | null;
|
|
68
|
+
customers: CustomersConfig | null;
|
|
69
|
+
}
|
|
70
|
+
/** Deploy one release of the company's app from its `build.json` (`build-json.ts`). */
|
|
71
|
+
export interface BuildChange {
|
|
72
|
+
field: 'build';
|
|
73
|
+
key: string;
|
|
74
|
+
repo: string;
|
|
75
|
+
release: string;
|
|
76
|
+
build: BuildJson;
|
|
77
|
+
}
|
|
55
78
|
/**
|
|
56
79
|
* One company-requested change to its own declared org, keyed by
|
|
57
80
|
* {@link RequestableField}. `classifyChange` and the store both switch on
|
|
58
81
|
* `.field`.
|
|
59
82
|
*/
|
|
60
|
-
export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
|
|
83
|
+
export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange | AppChange | BuildChange;
|
|
61
84
|
/**
|
|
62
85
|
* `auto`: nothing further is required -- archon may carry the request
|
|
63
86
|
* straight into a plan and apply it. `approval`: a human must approve the
|
|
@@ -72,7 +95,21 @@ export type ChangeClassification = 'auto' | 'approval';
|
|
|
72
95
|
* onward. Foundry only stores and classifies (#178 "Why"); nothing in this
|
|
73
96
|
* package transitions a state past its initial value.
|
|
74
97
|
*/
|
|
75
|
-
export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied';
|
|
98
|
+
export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied' | 'applying' | 'failed';
|
|
99
|
+
/** Why an `app` or `build` request is `failed`. */
|
|
100
|
+
export type FailureCode = 'replaced' | 'refused' | 'busy' | 'timed_out' | 'apply_error';
|
|
101
|
+
/** Who asked: a person in Boule, or a release build. `requestedBy` keeps the raw principal id for audit. */
|
|
102
|
+
export type RequestedBy = {
|
|
103
|
+
via: 'boule';
|
|
104
|
+
person: {
|
|
105
|
+
id: string;
|
|
106
|
+
name: string;
|
|
107
|
+
};
|
|
108
|
+
} | {
|
|
109
|
+
via: 'build';
|
|
110
|
+
repo: string;
|
|
111
|
+
release: string;
|
|
112
|
+
};
|
|
76
113
|
/**
|
|
77
114
|
* One company-requested change to its own declared org, as stored --
|
|
78
115
|
* `migrations/0017_requested_changes.sql`'s `foundry_requested_changes`
|
|
@@ -85,6 +122,15 @@ export interface RequestedChange {
|
|
|
85
122
|
requestedBy: string;
|
|
86
123
|
createdAt: string;
|
|
87
124
|
state: RequestedChangeState;
|
|
125
|
+
/** Null for rows made before `app` and `build` requests. */
|
|
126
|
+
requester: RequestedBy | null;
|
|
127
|
+
idempotencyKey: string | null;
|
|
128
|
+
reason: {
|
|
129
|
+
code: FailureCode;
|
|
130
|
+
message: string;
|
|
131
|
+
replacedBy?: number;
|
|
132
|
+
} | null;
|
|
133
|
+
updatedAt: string;
|
|
88
134
|
}
|
|
89
135
|
/**
|
|
90
136
|
* Thrown by {@link classifyChange} (and by the requested-change store's
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateCustomers } from './org.js';
|
|
1
|
+
import { validateCustomers, } from './org.js';
|
|
2
2
|
export const REQUESTABLE_FIELDS = [
|
|
3
3
|
'calls',
|
|
4
4
|
'customers',
|
|
@@ -6,6 +6,8 @@ export const REQUESTABLE_FIELDS = [
|
|
|
6
6
|
'aiLimit',
|
|
7
7
|
'domain',
|
|
8
8
|
'mailTier',
|
|
9
|
+
'app',
|
|
10
|
+
'build',
|
|
9
11
|
];
|
|
10
12
|
/**
|
|
11
13
|
* Thrown by {@link classifyChange} (and by the requested-change store's
|
|
@@ -42,6 +44,9 @@ function applyRequestedChange(org, change) {
|
|
|
42
44
|
return { ...org, domain: change.domain };
|
|
43
45
|
case 'mailTier':
|
|
44
46
|
return { ...org, mail: { ...org.mail, mode: change.mode } };
|
|
47
|
+
case 'app':
|
|
48
|
+
case 'build':
|
|
49
|
+
return org;
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
/**
|
|
@@ -115,6 +120,9 @@ export function classifyChange(org, change, plan) {
|
|
|
115
120
|
if (!REQUESTABLE_FIELDS.includes(change.field)) {
|
|
116
121
|
throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
|
|
117
122
|
}
|
|
123
|
+
// Archon validates the whole company for these; nothing waits for approval.
|
|
124
|
+
if (change.field === 'app' || change.field === 'build')
|
|
125
|
+
return 'auto';
|
|
118
126
|
if (change.field === 'customers') {
|
|
119
127
|
validateCustomersChange(change);
|
|
120
128
|
// The org after the change must still pass the rule apply checks, for example open registration
|
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 { OrgRefusal, OrgRefusalCode } from '../org.js';
|
|
2
3
|
import { type ReleaseStore } from '../release/state.js';
|
|
3
4
|
import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
|
|
4
5
|
/**
|
|
@@ -38,7 +39,10 @@ export declare function uniqueViolationConstraint(error: unknown): string | unde
|
|
|
38
39
|
* is that throw.
|
|
39
40
|
*/
|
|
40
41
|
export declare class DeclaredOrgError extends Error {
|
|
41
|
-
|
|
42
|
+
/** Set when a hosting or customers validator refused the write: which rule, and where in the org. */
|
|
43
|
+
readonly code?: OrgRefusalCode;
|
|
44
|
+
readonly path?: string;
|
|
45
|
+
constructor(message: string, refusal?: Pick<OrgRefusal, 'code' | 'path'>);
|
|
42
46
|
}
|
|
43
47
|
/**
|
|
44
48
|
* `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and
|