@wtfalch/foundry 0.47.0 → 0.49.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 (51) hide show
  1. package/dist/bootstrap/declare.js +1 -0
  2. package/dist/index.d.ts +6 -4
  3. package/dist/index.js +3 -2
  4. package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
  5. package/dist/migrations/0060_hosting_repos.sql +7 -0
  6. package/dist/org.d.ts +25 -1
  7. package/dist/org.js +51 -3
  8. package/dist/plan/archon-blue-green.js +15 -1
  9. package/dist/plan/blue-green-apply.d.ts +35 -0
  10. package/dist/plan/blue-green-apply.js +167 -16
  11. package/dist/plan/blue-green.d.ts +97 -2
  12. package/dist/plan/blue-green.js +214 -13
  13. package/dist/plan/database-dump.d.ts +37 -0
  14. package/dist/plan/database-dump.js +101 -0
  15. package/dist/plan/diff.js +3 -0
  16. package/dist/plan/executor.d.ts +48 -0
  17. package/dist/plan/executor.js +79 -20
  18. package/dist/plan/github-release-hook.d.ts +81 -0
  19. package/dist/plan/github-release-hook.js +159 -0
  20. package/dist/plan/index.d.ts +4 -1
  21. package/dist/plan/index.js +5 -1
  22. package/dist/plan/live.d.ts +1 -1
  23. package/dist/plan/locked-hostnames.d.ts +1 -0
  24. package/dist/plan/locked-hostnames.js +1 -0
  25. package/dist/plan/plan-apply.js +16 -7
  26. package/dist/plan/platform-diff.d.ts +59 -0
  27. package/dist/plan/platform-diff.js +100 -2
  28. package/dist/plan/platform-providers.d.ts +10 -0
  29. package/dist/plan/platform-providers.js +73 -1
  30. package/dist/plan/proxy-labels.d.ts +13 -1
  31. package/dist/plan/proxy-labels.js +26 -3
  32. package/dist/plan/release-host.d.ts +1 -1
  33. package/dist/plan/release-host.js +36 -14
  34. package/dist/plan/stage-env.d.ts +38 -0
  35. package/dist/plan/stage-env.js +52 -0
  36. package/dist/plan/stage-guards.d.ts +104 -0
  37. package/dist/plan/stage-guards.js +149 -0
  38. package/dist/plan/stage-isolation.d.ts +57 -0
  39. package/dist/plan/stage-isolation.js +248 -0
  40. package/dist/refuse-org.d.ts +1 -1
  41. package/dist/refuse-org.js +5 -3
  42. package/dist/release/decide.d.ts +5 -0
  43. package/dist/release/decide.js +10 -6
  44. package/dist/release/health.d.ts +6 -1
  45. package/dist/release/health.js +10 -2
  46. package/dist/release/run.d.ts +2 -0
  47. package/dist/release/run.js +2 -1
  48. package/dist/store/postgres.js +32 -2
  49. package/dist/store/schema.d.ts +58 -1
  50. package/dist/store/schema.js +5 -0
  51. package/package.json +1 -1
@@ -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;
@@ -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, normalizeHostingRepos, 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: {
@@ -170,6 +176,7 @@ function rowToOrg(row) {
170
176
  issuerApps: row.hostingIssuerApps,
171
177
  entries: row.hostingEntries.map((entry) => storedEntryToEntry(entry, row.hostingUsesDomain)),
172
178
  gateLimit: row.hostingGateLimit,
179
+ repos: row.hostingRepos,
173
180
  },
174
181
  sso: { enabled: row.ssoEnabled },
175
182
  guests: { enabled: row.guestsEnabled },
@@ -193,6 +200,13 @@ function readVersionPin(row) {
193
200
  }
194
201
  return pin;
195
202
  }
203
+ function readBlueGreen(stored, app) {
204
+ const reason = validateBlueGreen(stored, app);
205
+ if (reason) {
206
+ throw new DeclaredOrgError(`a stored org has an invalid ${app}.blueGreen: ${reason}`);
207
+ }
208
+ return stored;
209
+ }
196
210
  function orgToRow(org) {
197
211
  return {
198
212
  id: org.id,
@@ -215,6 +229,8 @@ function orgToRow(org) {
215
229
  signInInApp: org.signIn?.inApp ?? false,
216
230
  customersEnabled: org.customers?.enabled ?? false,
217
231
  customersSignUp: org.customers?.signUp ?? false,
232
+ bouleBlueGreen: org.boule?.blueGreen ?? null,
233
+ agoraBlueGreen: org.agora?.blueGreen ?? null,
218
234
  servicesOwn: org.services?.own ?? false,
219
235
  servicesSfuSlot: org.services?.sfuSlot ?? null,
220
236
  versionPin: org.versionPin && Object.keys(org.versionPin).length > 0 ? org.versionPin : null,
@@ -226,6 +242,7 @@ function orgToRow(org) {
226
242
  hostingIssuerApps: org.hosting.issuerApps,
227
243
  hostingEntries: org.hosting.entries,
228
244
  hostingGateLimit: org.hosting.gateLimit,
245
+ hostingRepos: normalizeHostingRepos(org.hosting.repos),
229
246
  ssoEnabled: org.sso.enabled,
230
247
  guestsEnabled: org.guests.enabled,
231
248
  soloEnabled: org.solo.enabled,
@@ -422,6 +439,10 @@ export function createPostgresDeclaredOrgStore(db) {
422
439
  if (customersRefusal) {
423
440
  throw new DeclaredOrgError(`customers invalid: ${customersRefusal.message}`, customersRefusal);
424
441
  }
442
+ const builtinRefusal = refuseBuiltinBlueGreen(org);
443
+ if (builtinRefusal) {
444
+ throw new DeclaredOrgError(`blue/green invalid: ${builtinRefusal.message}`, builtinRefusal);
445
+ }
425
446
  const slot = org.services?.sfuSlot ?? null;
426
447
  if (slot === null)
427
448
  return;
@@ -550,7 +571,16 @@ export function createPostgresDeclaredOrgStore(db) {
550
571
  // just be re-litigating a write this update isn't making.
551
572
  if (patch.slug !== undefined && patch.slug !== current.slug)
552
573
  requireValidSlug(patch.slug);
553
- const next = { ...current, ...patch, id: current.id, version: current.version };
574
+ const merged = {
575
+ ...current,
576
+ ...patch,
577
+ id: current.id,
578
+ version: current.version,
579
+ };
580
+ const next = {
581
+ ...merged,
582
+ hosting: { ...merged.hosting, repos: normalizeHostingRepos(merged.hosting.repos) },
583
+ };
554
584
  requireValidDomain(next.domain);
555
585
  requireValidLimits(next);
556
586
  requireValidBuyer(next);
@@ -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";
@@ -654,6 +692,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
654
692
  identity: undefined;
655
693
  generated: undefined;
656
694
  }, {}, {}>;
695
+ hostingRepos: import("drizzle-orm/pg-core").PgColumn<{
696
+ name: "hosting_repos";
697
+ tableName: "foundry_declared_orgs";
698
+ dataType: "json";
699
+ columnType: "PgJsonb";
700
+ data: string[];
701
+ driverParam: unknown;
702
+ notNull: true;
703
+ hasDefault: true;
704
+ isPrimaryKey: false;
705
+ isAutoincrement: false;
706
+ hasRuntimeDefault: false;
707
+ enumValues: undefined;
708
+ baseColumn: never;
709
+ identity: undefined;
710
+ generated: undefined;
711
+ }, {}, {
712
+ $type: string[];
713
+ }>;
657
714
  ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
658
715
  name: "sso_enabled";
659
716
  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.
@@ -71,6 +74,8 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
71
74
  hostingEntries: jsonb('hosting_entries').notNull().default([]).$type(),
72
75
  // HostingConfig.gateLimit (org.ts, #603); migrations/0048. Default 3 is DEFAULT_GATE_LIMIT.
73
76
  hostingGateLimit: integer('hosting_gate_limit').notNull().default(3),
77
+ // HostingConfig.repos (org.ts); migrations/0060. Lowercase `wtfalch/<name>` strings.
78
+ hostingRepos: jsonb('hosting_repos').notNull().default([]).$type(),
74
79
  ssoEnabled: boolean('sso_enabled').notNull().default(false),
75
80
  guestsEnabled: boolean('guests_enabled').notNull().default(false),
76
81
  soloEnabled: boolean('solo_enabled').notNull().default(false),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.47.0",
3
+ "version": "0.49.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",