@wtfalch/foundry 0.47.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.
Files changed (44) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.js +1 -0
  3. package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
  4. package/dist/org.d.ts +17 -1
  5. package/dist/org.js +24 -3
  6. package/dist/plan/archon-blue-green.js +15 -1
  7. package/dist/plan/blue-green-apply.d.ts +35 -0
  8. package/dist/plan/blue-green-apply.js +167 -16
  9. package/dist/plan/blue-green.d.ts +97 -2
  10. package/dist/plan/blue-green.js +214 -13
  11. package/dist/plan/database-dump.d.ts +37 -0
  12. package/dist/plan/database-dump.js +101 -0
  13. package/dist/plan/diff.js +3 -0
  14. package/dist/plan/executor.d.ts +48 -0
  15. package/dist/plan/executor.js +79 -20
  16. package/dist/plan/index.d.ts +2 -0
  17. package/dist/plan/index.js +4 -0
  18. package/dist/plan/locked-hostnames.d.ts +1 -0
  19. package/dist/plan/locked-hostnames.js +1 -0
  20. package/dist/plan/plan-apply.js +16 -7
  21. package/dist/plan/platform-diff.js +2 -1
  22. package/dist/plan/platform-providers.js +2 -0
  23. package/dist/plan/proxy-labels.d.ts +13 -1
  24. package/dist/plan/proxy-labels.js +26 -3
  25. package/dist/plan/release-host.d.ts +1 -1
  26. package/dist/plan/release-host.js +36 -14
  27. package/dist/plan/stage-env.d.ts +38 -0
  28. package/dist/plan/stage-env.js +52 -0
  29. package/dist/plan/stage-guards.d.ts +104 -0
  30. package/dist/plan/stage-guards.js +149 -0
  31. package/dist/plan/stage-isolation.d.ts +57 -0
  32. package/dist/plan/stage-isolation.js +248 -0
  33. package/dist/refuse-org.d.ts +1 -1
  34. package/dist/refuse-org.js +5 -3
  35. package/dist/release/decide.d.ts +5 -0
  36. package/dist/release/decide.js +10 -6
  37. package/dist/release/health.d.ts +6 -1
  38. package/dist/release/health.js +10 -2
  39. package/dist/release/run.d.ts +2 -0
  40. package/dist/release/run.js +2 -1
  41. package/dist/store/postgres.js +20 -1
  42. package/dist/store/schema.d.ts +39 -1
  43. package/dist/store/schema.js +3 -0
  44. package/package.json +1 -1
@@ -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 = health !== null &&
204
- baseline !== null &&
205
- elapsed >= WINDOW_MIN_HOURS * HOUR &&
206
- newC.requests >= required &&
207
- rate(newC) <= baseline + ERROR_RATE_MARGIN;
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 };
@@ -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
- /** An HTTP check of the color's health path on its own address. */
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'>;
@@ -45,14 +45,22 @@ export async function readReleaseHealth(input) {
45
45
  return null;
46
46
  }
47
47
  }
48
- /** An HTTP check of the color's health path on its own address. */
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
- return res.ok ? 'ok' : 'failed';
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.
@@ -37,4 +37,6 @@ export declare function runRelease(input: {
37
37
  };
38
38
  now: Date;
39
39
  deps: ReleaseDeps;
40
+ /** No reporting health read: only hard fails count (see `decide`). */
41
+ hardOnly?: boolean;
40
42
  }): Promise<ReleaseRunResult>;
@@ -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,5 +1,5 @@
1
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, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
2
+ import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
3
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';
@@ -161,6 +161,12 @@ function rowToOrg(row) {
161
161
  ? { customers: { enabled: row.customersEnabled, signUp: row.customersSignUp } }
162
162
  : {}),
163
163
  // Omitted while unset. Validated on read: a stored value is not trusted.
164
+ ...(row.bouleBlueGreen
165
+ ? { boule: { blueGreen: readBlueGreen(row.bouleBlueGreen, 'boule') } }
166
+ : {}),
167
+ ...(row.agoraBlueGreen
168
+ ? { agora: { blueGreen: readBlueGreen(row.agoraBlueGreen, 'agora') } }
169
+ : {}),
164
170
  ...(row.versionPin ? { versionPin: readVersionPin(row) } : {}),
165
171
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
166
172
  hosting: {
@@ -193,6 +199,13 @@ function readVersionPin(row) {
193
199
  }
194
200
  return pin;
195
201
  }
202
+ function readBlueGreen(stored, app) {
203
+ const reason = validateBlueGreen(stored, app);
204
+ if (reason) {
205
+ throw new DeclaredOrgError(`a stored org has an invalid ${app}.blueGreen: ${reason}`);
206
+ }
207
+ return stored;
208
+ }
196
209
  function orgToRow(org) {
197
210
  return {
198
211
  id: org.id,
@@ -215,6 +228,8 @@ function orgToRow(org) {
215
228
  signInInApp: org.signIn?.inApp ?? false,
216
229
  customersEnabled: org.customers?.enabled ?? false,
217
230
  customersSignUp: org.customers?.signUp ?? false,
231
+ bouleBlueGreen: org.boule?.blueGreen ?? null,
232
+ agoraBlueGreen: org.agora?.blueGreen ?? null,
218
233
  servicesOwn: org.services?.own ?? false,
219
234
  servicesSfuSlot: org.services?.sfuSlot ?? null,
220
235
  versionPin: org.versionPin && Object.keys(org.versionPin).length > 0 ? org.versionPin : null,
@@ -422,6 +437,10 @@ export function createPostgresDeclaredOrgStore(db) {
422
437
  if (customersRefusal) {
423
438
  throw new DeclaredOrgError(`customers invalid: ${customersRefusal.message}`, customersRefusal);
424
439
  }
440
+ const builtinRefusal = refuseBuiltinBlueGreen(org);
441
+ if (builtinRefusal) {
442
+ throw new DeclaredOrgError(`blue/green invalid: ${builtinRefusal.message}`, builtinRefusal);
443
+ }
425
444
  const slot = org.services?.sfuSlot ?? null;
426
445
  if (slot === null)
427
446
  return;
@@ -1,4 +1,4 @@
1
- import type { AppEnvSource, HostingEntry, IssuerApp, OwnerIdentity } from '../org.js';
1
+ import type { AppEnvSource, BlueGreenConfig, HostingEntry, IssuerApp, OwnerIdentity } from '../org.js';
2
2
  import type { Plan } from '../plan.js';
3
3
  import type { Checkpoint, DestroyRecord } from '../state.js';
4
4
  /**
@@ -406,6 +406,44 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
406
406
  identity: undefined;
407
407
  generated: undefined;
408
408
  }, {}, {}>;
409
+ bouleBlueGreen: import("drizzle-orm/pg-core").PgColumn<{
410
+ name: "boule_blue_green";
411
+ tableName: "foundry_declared_orgs";
412
+ dataType: "json";
413
+ columnType: "PgJsonb";
414
+ data: BlueGreenConfig | null;
415
+ driverParam: unknown;
416
+ notNull: false;
417
+ hasDefault: false;
418
+ isPrimaryKey: false;
419
+ isAutoincrement: false;
420
+ hasRuntimeDefault: false;
421
+ enumValues: undefined;
422
+ baseColumn: never;
423
+ identity: undefined;
424
+ generated: undefined;
425
+ }, {}, {
426
+ $type: BlueGreenConfig | null;
427
+ }>;
428
+ agoraBlueGreen: import("drizzle-orm/pg-core").PgColumn<{
429
+ name: "agora_blue_green";
430
+ tableName: "foundry_declared_orgs";
431
+ dataType: "json";
432
+ columnType: "PgJsonb";
433
+ data: BlueGreenConfig | null;
434
+ driverParam: unknown;
435
+ notNull: false;
436
+ hasDefault: false;
437
+ isPrimaryKey: false;
438
+ isAutoincrement: false;
439
+ hasRuntimeDefault: false;
440
+ enumValues: undefined;
441
+ baseColumn: never;
442
+ identity: undefined;
443
+ generated: undefined;
444
+ }, {}, {
445
+ $type: BlueGreenConfig | null;
446
+ }>;
409
447
  versionPin: import("drizzle-orm/pg-core").PgColumn<{
410
448
  name: "version_pin";
411
449
  tableName: "foundry_declared_orgs";
@@ -43,6 +43,9 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
43
43
  // CustomersConfig (org.ts); migrations/0057.
44
44
  customersEnabled: boolean('customers_enabled').notNull().default(false),
45
45
  customersSignUp: boolean('customers_sign_up').notNull().default(false),
46
+ // OrgBuiltinApp.blueGreen (org.ts) of Boule and Agora; `migrations/0059_org_builtin_blue_green.sql`. Null is off.
47
+ bouleBlueGreen: jsonb('boule_blue_green').$type(),
48
+ agoraBlueGreen: jsonb('agora_blue_green').$type(),
46
49
  // VersionPin (org.ts): null follows the platform. `migrations/0053_org_version_pin.sql`.
47
50
  versionPin: jsonb('version_pin').$type(),
48
51
  // SiteConfig (org.ts, T10). A brand new org has no cms site.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",