@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.
@@ -170,6 +170,8 @@ export function declaredPlatformFor(apex, serverIpv4) {
170
170
  // only after Archon runs. Declared on with no pin, it would be reported
171
171
  // blocked and the bootstrap would never be complete.
172
172
  uptime: { enabled: false, tag: '', sha256: '' },
173
+ // foundry#1024: off until a human pins an edge release tag and digest, the same reason as uptime.
174
+ usageTail: { enabled: false, tag: '', sha256: '' },
173
175
  heartbeatsProvider: 'self-hosted',
174
176
  };
175
177
  }
package/dist/index.d.ts CHANGED
@@ -25,6 +25,8 @@ export { createPostgresAuditLog } from './store/audit.js';
25
25
  export type { Color, ReleaseAction, ReleaseEvent, ReleaseEventKind, ReleaseSeries, ReleaseSeriesColor, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
26
26
  export type { ReleaseStore } from './release/state.js';
27
27
  export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
28
+ export type { BuiltinFanOut, FanOutApp, FanOutOrgState } from './release/fanout.js';
29
+ export { readBuiltinFanOut } from './release/fanout.js';
28
30
  export type { ReleaseSeriesHost, ReleaseSeriesQuery } from './release/series.js';
29
31
  export { readReleaseSeries } from './release/series.js';
30
32
  export type { EntrySeriesDeps } from './plan/release-series-reader.js';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export { StaleLeaseError } from './store.js';
15
15
  export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
16
16
  export { createPostgresAuditLog } from './store/audit.js';
17
17
  export { readReleaseHistory, readReleaseState, requestReleaseAction } from './release/state.js';
18
+ export { readBuiltinFanOut } from './release/fanout.js';
18
19
  export { readReleaseSeries } from './release/series.js';
19
20
  export { readEntryReleaseSeries } from './plan/release-series-reader.js';
20
21
  export { checkMigrationCompatibility } from './release/migration-compatibility.js';
@@ -0,0 +1,12 @@
1
+ -- @wtfalch/foundry: the usage tail Worker on the declared platform (foundry#1024).
2
+ -- Mirrors `src/store/schema.ts`'s `foundryDeclaredPlatform` additions.
3
+ -- `usage_tail_tag` and `usage_tail_sha256` are the pin of the edge release
4
+ -- whose built Worker foundry deploys; both empty means nothing is pinned
5
+ -- yet, and the planner reports that instead of planning the step. Unlike the
6
+ -- uptime Worker, this one is off by default: nothing has pinned a release
7
+ -- yet on any existing platform.
8
+
9
+ alter table foundry_declared_platform
10
+ add column if not exists usage_tail_enabled boolean not null default false,
11
+ add column if not exists usage_tail_tag text not null default '',
12
+ add column if not exists usage_tail_sha256 text not null default '';
package/dist/plan/diff.js CHANGED
@@ -176,6 +176,18 @@ function liveResourceName(r) {
176
176
  const type = r.kind === 'dns_record' && typeof r.config.type === 'string' ? `${r.config.type} ` : '';
177
177
  return `${type}${name}, ${r.key}`;
178
178
  }
179
+ /**
180
+ * The provider's own name for a live resource, for a park or provision
181
+ * step's own prose (#1062, pain point 3): an operator told to "park the
182
+ * database" acted on the company's Coolify server, named after the same
183
+ * company, instead of the database resource the step meant. `null` when
184
+ * there is no live resource yet, or it carries no name -- unlike
185
+ * {@link liveResourceName} above, this never falls back to the bare key,
186
+ * since an id is not a name a step's own text should ever show.
187
+ */
188
+ function liveName(live) {
189
+ return typeof live?.config.name === 'string' && live.config.name !== '' ? live.config.name : null;
190
+ }
179
191
  /**
180
192
  * The diff engine: #19's "a plan reads live state from Zitadel, Coolify and
181
193
  * Cloudflare and diffs it against the declared org", built on #10's rule
@@ -3166,7 +3178,7 @@ function planKosmos(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
3166
3178
  if (!kosmosOn(org)) {
3167
3179
  // Switched off: park a live app (stop it, keep the database), like CMS and calls. A stopped app is
3168
3180
  // already parked, so the plan reaches empty.
3169
- const parked = planHostingToggle('coolify', 'kosmos', false, org, byKey, claimed, () => `provision ${org.slug}'s kosmos`, () => `park ${org.slug}'s kosmos (stops serving; its database is retained, not deleted)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
3181
+ const parked = planHostingToggle('coolify', 'kosmos', false, org, byKey, claimed, () => `provision ${org.slug}'s kosmos`, (live) => `park ${org.slug}'s kosmos${liveName(live) ? ` ${liveName(live)}` : ''} (stops serving; its database is retained, not deleted)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
3170
3182
  return { step: parked, blocked: null };
3171
3183
  }
3172
3184
  const key = resourceKey('coolify', 'kosmos', org.id);
@@ -3533,7 +3545,11 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
3533
3545
  * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
3534
3546
  * the org's own app, database or tunnel configuration, it parks it.
3535
3547
  */
3536
- function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
3548
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed,
3549
+ // #1062: takes the live resource so a caller can name it (Coolify's own
3550
+ // name for it, from `config.name`), rather than a generic noun a park or
3551
+ // provision step's own text can be told apart from the server it runs on.
3552
+ describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
3537
3553
  const key = resourceKey(system, kind, stepKey);
3538
3554
  const live = byKey.get(key);
3539
3555
  // A live resource is on unless it says it is parked. Providers' own
@@ -3551,7 +3567,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
3551
3567
  return null;
3552
3568
  return {
3553
3569
  action: { system, kind, key: stepKey, desired: { enabled: false } },
3554
- step: { description: describePark(), destroys: true, migrates: false },
3570
+ step: { description: describePark(live), destroys: true, migrates: false },
3555
3571
  };
3556
3572
  }
3557
3573
  if (live)
@@ -3562,7 +3578,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
3562
3578
  return null;
3563
3579
  return {
3564
3580
  action: { system, kind, key: stepKey, desired: { enabled: true } },
3565
- step: { description: describeProvision(), destroys: false, migrates: false },
3581
+ step: { description: describeProvision(live), destroys: false, migrates: false },
3566
3582
  };
3567
3583
  }
3568
3584
  /**
@@ -3701,7 +3717,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3701
3717
  });
3702
3718
  }
3703
3719
  const refused = noRepo || composeBuilt;
3704
- const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}${volumesNote(entry)}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
3720
+ const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}${volumesNote(entry)}`, (live) => `park ${org.slug}'s ${label}${liveName(live) ? ` ${liveName(live)}` : ''} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
3705
3721
  // #447: only the live app's own `git_repository` decides, never the
3706
3722
  // declared entry (a `repo: null` entry can sit over a bespoke app, which is
3707
3723
  // parked as before). Only the `app` key ever held the default app, and an
@@ -4253,7 +4269,7 @@ function planDatabase(org, byKey, claimed, recordedIds) {
4253
4269
  if (!appHome || !legacy || !recordedIds[legacy] || serviceOnServer(org, appHome, recordedIds)) {
4254
4270
  return null;
4255
4271
  }
4256
- return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
4272
+ return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, (live) => `park ${org.slug}'s database${liveName(live) ? ` ${liveName(live)}` : ''} (data is retained, not deleted -- #7 corrections 1)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
4257
4273
  }
4258
4274
  function sortedNames(names) {
4259
4275
  return names.filter(Boolean).sort().join(',');
@@ -4298,7 +4314,7 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
4298
4314
  appsOn(org) ||
4299
4315
  // A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
4300
4316
  entryRoutes(org, recordedIds).length > 0;
4301
- const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
4317
+ const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, (live) => `park ${org.slug}'s Cloudflare tunnel${liveName(live) ? ` ${liveName(live)}` : ''} (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
4302
4318
  // Cloudflare's status, read against what is declared: a park cannot change
4303
4319
  // it, so judging on/off by status alone would never let the plan empty.
4304
4320
  // Wanted on: only `down` needs a repair (`inactive` is a fresh tunnel
@@ -115,6 +115,15 @@ export interface AccessGateInput {
115
115
  emails: string[];
116
116
  }
117
117
  export declare const isNotFound: (error: unknown) => boolean;
118
+ /**
119
+ * Throws the operator-facing message when `error` is edge's generic 404 for
120
+ * a route this SDK expects but the running release predates (#1061, pain
121
+ * point 12: production edge six days behind the SDK failed foundry's first
122
+ * apps step with the raw "Route not found" and no hint why). Returns
123
+ * otherwise, so the caller's own handling of `error` -- a conflict branch,
124
+ * an `isNotFound`/`isNotFoundError` "already gone" check -- still runs.
125
+ */
126
+ export declare function edgeTooOld(error: unknown, what: string): void;
118
127
  /**
119
128
  * Edge answers a policy, delete or create call for a live Access app with no
120
129
  * edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
@@ -3,6 +3,26 @@ import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-d
3
3
  export const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
4
4
  /** Edge's 409 for a live app on the domain with no edge row. */
5
5
  const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && error.reason === 'exists';
6
+ /**
7
+ * Edge's generic 404 fallback for a route no release of it answers yet
8
+ * (`service.ts`'s `errorResponse('not_found', 'Route not found')`), as
9
+ * opposed to a resource-specific 404 ("Key not found" and the like), which
10
+ * never carries this exact message.
11
+ */
12
+ const isRouteMissing = (error) => error instanceof EdgeApiError && error.status === 404 && error.message === 'Route not found';
13
+ /**
14
+ * Throws the operator-facing message when `error` is edge's generic 404 for
15
+ * a route this SDK expects but the running release predates (#1061, pain
16
+ * point 12: production edge six days behind the SDK failed foundry's first
17
+ * apps step with the raw "Route not found" and no hint why). Returns
18
+ * otherwise, so the caller's own handling of `error` -- a conflict branch,
19
+ * an `isNotFound`/`isNotFoundError` "already gone" check -- still runs.
20
+ */
21
+ export function edgeTooOld(error, what) {
22
+ if (isRouteMissing(error)) {
23
+ throw new Error(`${what} is blocked: the running edge has no route for this step; deploy edge to the release foundry expects`);
24
+ }
25
+ }
6
26
  /**
7
27
  * Edge answers a policy, delete or create call for a live Access app with no
8
28
  * edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
@@ -2489,6 +2489,22 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2489
2489
  }>;
2490
2490
  down: string[];
2491
2491
  }>;
2492
+ /**
2493
+ * The built-in fan-out's in-place deploy (`release/fanout.ts`): pins the org's Boule or Agora to `sha`, deploys
2494
+ * it and waits at most STAGE_DEPLOY_ATTEMPTS polls (10 minutes) for Coolify to finish. The reasons are fixed
2495
+ * texts. Never touches a copy that runs blue/green: that one has colours and is released by its own runner.
2496
+ */
2497
+ deployBuiltinAt(input: {
2498
+ app: 'boule' | 'agora';
2499
+ org: DeclaredOrg;
2500
+ recordedIds: Readonly<Record<string, string>>;
2501
+ sha: string;
2502
+ }): Promise<{
2503
+ ok: true;
2504
+ } | {
2505
+ ok: false;
2506
+ reason: string;
2507
+ }>;
2492
2508
  /**
2493
2509
  * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
2494
2510
  * blue copy is not recorded. The health token is only read here, never minted.
@@ -30,12 +30,12 @@ import { BACKUP_POSTGRES_DATABASES_SCOPE_KEY, BACKUP_SCOPE_PREFIX, COMPANY_BACKU
30
30
  import { LOGIN_COOKIE_DOMAIN_SCOPE_KEY } from './cookie-domain.js';
31
31
  import { previewsOn } from './coolify-previews.js';
32
32
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
33
- import { waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
33
+ import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
34
34
  import { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_DOCKERFILE, DESKTOP_AUTH_HOME, DESKTOP_AUTH_ORIGIN_SCOPE_KEY, DESKTOP_AUTH_READY_PATH, DESKTOP_AUTH_READY_SCOPE_KEY, DESKTOP_AUTH_REDIRECT_SCOPE_KEY, DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthCallbackUri, desktopAuthEnv, desktopAuthInputs, desktopAuthRedirectUnsupported, desktopAuthRevision, } from './desktop-auth.js';
35
35
  import { desktopCallbackUri, signInCallbackAppliedValue, signInRedirectUris, unionRedirectUris, zitadelAppDesktopCallbackAppliedScopeKey, } from './desktop-callback.js';
36
36
  import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, placedOnPlatform, } from './destinations.js';
37
37
  import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
38
- import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
38
+ import { createEdgeAccess, edgeTooOld, isNotFound as isEdgeNotFound, } from './edge-access.js';
39
39
  import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
40
40
  import { createEdgeRateLimits, } from './edge-ratelimits.js';
41
41
  import { createEdgeTunnels } from './edge-tunnels.js';
@@ -77,7 +77,7 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
77
77
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
78
78
  import { companyAppPostLogoutUris, postLogoutAppliedValue, postLogoutUrisFor, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
79
79
  import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsAppsZoneName, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
80
- import { createReleaseHost } from './release-host.js';
80
+ import { STAGE_DEPLOY_ATTEMPTS, STAGE_DEPLOY_INTERVAL_MS, createReleaseHost, } from './release-host.js';
81
81
  import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
82
82
  import { editSignInBindings } from './sign-in-bindings.js';
83
83
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
@@ -3183,6 +3183,40 @@ export class FoundryStepExecutor {
3183
3183
  down,
3184
3184
  };
3185
3185
  }
3186
+ /**
3187
+ * The built-in fan-out's in-place deploy (`release/fanout.ts`): pins the org's Boule or Agora to `sha`, deploys
3188
+ * it and waits at most STAGE_DEPLOY_ATTEMPTS polls (10 minutes) for Coolify to finish. The reasons are fixed
3189
+ * texts. Never touches a copy that runs blue/green: that one has colours and is released by its own runner.
3190
+ */
3191
+ async deployBuiltinAt(input) {
3192
+ const { app, org, sha } = input;
3193
+ if (!/^[0-9a-f]{40}$/.test(sha))
3194
+ return { ok: false, reason: 'not a built commit' };
3195
+ if (org[app]?.blueGreen !== undefined)
3196
+ return { ok: false, reason: 'copy runs blue/green' };
3197
+ const appId = input.recordedIds[`coolify:${app}:${org.id}`];
3198
+ if (!appId)
3199
+ return { ok: false, reason: 'app not recorded' };
3200
+ try {
3201
+ await this.ports.coolify.updateApplication(appId, { git_commit_sha: sha });
3202
+ }
3203
+ catch (error) {
3204
+ console.error(`fanout ${app}: selecting the commit failed (${error instanceof Error ? error.name : 'Error'})`);
3205
+ return { ok: false, reason: 'select commit failed' };
3206
+ }
3207
+ try {
3208
+ await waitForDeploymentFinished(this.ports.coolify, appId, {
3209
+ ...(this.ports.runtime ?? {}),
3210
+ deployAttempts: STAGE_DEPLOY_ATTEMPTS,
3211
+ deployIntervalMs: STAGE_DEPLOY_INTERVAL_MS,
3212
+ }, `coolify:fanout:${app}`, 'the copy stays as it was');
3213
+ }
3214
+ catch (error) {
3215
+ console.error(`fanout ${app}: the deployment did not finish (${error instanceof Error ? error.name : 'Error'})`);
3216
+ return { ok: false, reason: 'deployment did not finish' };
3217
+ }
3218
+ return { ok: true };
3219
+ }
3186
3220
  /**
3187
3221
  * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
3188
3222
  * blue copy is not recorded. The health token is only read here, never minted.
@@ -10853,6 +10887,7 @@ export class FoundryStepExecutor {
10853
10887
  await edge.revokeKey(org.id, id);
10854
10888
  }
10855
10889
  catch (error) {
10890
+ edgeTooOld(error, at);
10856
10891
  // A 404 means it is already gone -- recorded as retired below,
10857
10892
  // the same as a successful revoke. Anything else is a real
10858
10893
  // failure: thrown, not swallowed, so the key stays live and the
@@ -10922,18 +10957,26 @@ export class FoundryStepExecutor {
10922
10957
  await edge.revokeKey(org.id, staleUndelivered);
10923
10958
  }
10924
10959
  catch (error) {
10960
+ edgeTooOld(error, at);
10925
10961
  if (!isNotFoundError(error))
10926
10962
  throw error;
10927
10963
  }
10928
10964
  context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
10929
10965
  await context.persistScopeIds();
10930
10966
  }
10931
- const minted = await edge.mintKey({
10932
- organisationId: org.id,
10933
- domain: edgeKeyDomain(org),
10934
- name: desired.name,
10935
- expiresDays: EDGE_ENTRY_KEY_DAYS,
10936
- });
10967
+ let minted;
10968
+ try {
10969
+ minted = await edge.mintKey({
10970
+ organisationId: org.id,
10971
+ domain: edgeKeyDomain(org),
10972
+ name: desired.name,
10973
+ expiresDays: EDGE_ENTRY_KEY_DAYS,
10974
+ });
10975
+ }
10976
+ catch (e) {
10977
+ edgeTooOld(e, at);
10978
+ throw e;
10979
+ }
10937
10980
  context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), minted.id);
10938
10981
  await context.persistScopeIds();
10939
10982
  const envs = [
@@ -11094,6 +11137,7 @@ export class FoundryStepExecutor {
11094
11137
  await edge.setOrganisationSlug(org.id, slug);
11095
11138
  }
11096
11139
  catch (e) {
11140
+ edgeTooOld(e, `edge:slug for ${org.slug}`);
11097
11141
  if (e instanceof EdgeApiError && e.code === 'conflict') {
11098
11142
  throw new Error(`edge:slug for ${org.slug} is blocked: edge says another company already holds the slug '${slug}', so its platform hostnames cannot be accepted; change the slug`);
11099
11143
  }
@@ -11113,7 +11157,13 @@ export class FoundryStepExecutor {
11113
11157
  throw new Error(`edge:reserved-labels for ${org.slug}: this executor was built without an EdgePort (#317)`);
11114
11158
  }
11115
11159
  const labels = action.desired.labels;
11116
- await edge.setReservedLabels(org.id, labels);
11160
+ try {
11161
+ await edge.setReservedLabels(org.id, labels);
11162
+ }
11163
+ catch (e) {
11164
+ edgeTooOld(e, `edge:reserved-labels for ${org.slug}`);
11165
+ throw e;
11166
+ }
11117
11167
  context.recordScopeId(EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
11118
11168
  return {};
11119
11169
  }
@@ -11658,6 +11708,7 @@ export class FoundryStepExecutor {
11658
11708
  await edge.revokeKey(org.id, target.id);
11659
11709
  }
11660
11710
  catch (error) {
11711
+ edgeTooOld(error, `edge:key '${target.key}' for ${org.slug}`);
11661
11712
  // Already gone -- the same already-retired key a retire step
11662
11713
  // handled before org destroy ever ran can still reach here
11663
11714
  // (#317 review); a 404 is success, not a reason to block
@@ -11,7 +11,7 @@ import { ownHostnames } from './own-hostnames.js';
11
11
  import { JOBS_PUBLIC_URL_SCOPE_KEY, OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY } from './own-jobs-route.js';
12
12
  import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } from './own-services.js';
13
13
  import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_NODE_IP_SCOPE_KEY, OWN_SFU_APPLIED_SLOT_SCOPE_KEY, OWN_SFU_APPLIED_VERSION_SCOPE_KEY, OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, SFU_INTERNAL_URL_SCOPE_KEY, SFU_PUBLIC_URL_SCOPE_KEY, } from './own-sfu.js';
14
- import { AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, orgDeclaresMcp, platformMailMxHost, } from './platform-diff.js';
14
+ import { AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, authLoginEnvMarker, orgDeclaresMcp, platformMailMxHost, } from './platform-diff.js';
15
15
  import { OWN_FILES_RUNNER_SCOPE_KEY } from './platform-org.js';
16
16
  import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
17
17
  import { SIGN_IN_LOGIN_APP_SCOPE_KEY, inAppSignIn } from './sign-in.js';
@@ -217,6 +217,9 @@ export function planOwnAuth(options) {
217
217
  recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY] &&
218
218
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
219
219
  recordedIds[PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY] === AUTH_TUNNEL_ROUTES_VERSION &&
220
+ // The login apps' mail env (`LOGIN_MAIL_FROM`, the Lettermint key); a stack from before it is written again.
221
+ recordedIds[PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY] ===
222
+ authLoginEnvMarker(`noreply@${names.mailDomain}`) &&
220
223
  // The code mail carries the company's name and design; an older apply, or a renamed company, writes it again.
221
224
  (options.signinMailWritable === false ||
222
225
  recordedIds[PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY] === signinMailMarker(org.displayName)) &&
@@ -234,6 +234,21 @@ export interface StepExecutor {
234
234
  * copy is not recorded. Optional: an executor without it runs no release.
235
235
  */
236
236
  releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
237
+ /**
238
+ * The built-in fan-out's in-place deploy of one org's Boule or Agora at `sha` (`release/fanout.ts`). Optional:
239
+ * an executor without it runs no fan-out.
240
+ */
241
+ deployBuiltinAt?(input: {
242
+ app: 'boule' | 'agora';
243
+ org: DeclaredOrg;
244
+ recordedIds: Readonly<Record<string, string>>;
245
+ sha: string;
246
+ }): Promise<{
247
+ ok: true;
248
+ } | {
249
+ ok: false;
250
+ reason: string;
251
+ }>;
237
252
  /**
238
253
  * One release tick for one subject: the recorded stage gate's list follows the people holding `releases:preview`,
239
254
  * owners staying. Never throws. Optional: an executor without it leaves every gate as it is.
@@ -337,6 +352,8 @@ export interface PlatformStepExecutor {
337
352
  authExecutorMissing?: boolean;
338
353
  /** #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`. */
339
354
  authOtherInputsMissing?: string[];
355
+ /** The sealed mail key resolved: `planAuth` asks for the login env marker only when it did. */
356
+ authMailKeyHeld?: boolean;
340
357
  files?: string;
341
358
  integrations?: string;
342
359
  ai?: string;
@@ -933,6 +950,12 @@ export declare class FoundryPlanApply implements PlanApply {
933
950
  target: string;
934
951
  error?: string;
935
952
  }>>;
953
+ /**
954
+ * One fan-out pass for the owner org's built-in `app` (`release/fanout.ts`): the other orgs' copies are put on
955
+ * the commit the owner's copy runs, one after the other, in place, stopping at the first failure. Each state
956
+ * write takes the owner's lease on its own; no lease is held while a deploy waits.
957
+ */
958
+ private runBuiltinFanOut;
936
959
  /**
937
960
  * Phase 2 for one org: takes the copy lock of each stage copy request under the org's lease, then runs the
938
961
  * copies after the lease is released. While a copy runs, every other tick and apply for the org goes on, a
@@ -4,6 +4,7 @@ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
4
4
  import { sfuSlotPorts } from '../calls-sfu.js';
5
5
  import { ORG_SERVICE_SCHEMAS, heldEnvRef, ownsServices, validateHosting } from '../org.js';
6
6
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
7
+ import { runBuiltinFanOut } from '../release/fanout.js';
7
8
  import { recordedFrom, releaseKeys } from '../release/state.js';
8
9
  import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
9
10
  import { StaleLeaseError, } from '../store.js';
@@ -1338,6 +1339,16 @@ export class FoundryPlanApply {
1338
1339
  settle('platform', e);
1339
1340
  }
1340
1341
  }
1342
+ // Between the phases: once the owner's Boule or Agora is fully live, copy it to every other org. Its errors
1343
+ // are reported by app, never as an org.
1344
+ for (const app of ['boule', 'agora']) {
1345
+ try {
1346
+ await this.runBuiltinFanOut(app);
1347
+ }
1348
+ catch (e) {
1349
+ settle(`fanout:${app}`, e);
1350
+ }
1351
+ }
1341
1352
  // Phase 2: the copies, one subject at a time. A copy takes its lock under the org's lease and runs without it.
1342
1353
  for (const { target, run } of copying) {
1343
1354
  try {
@@ -1350,6 +1361,65 @@ export class FoundryPlanApply {
1350
1361
  }
1351
1362
  return results;
1352
1363
  }
1364
+ /**
1365
+ * One fan-out pass for the owner org's built-in `app` (`release/fanout.ts`): the other orgs' copies are put on
1366
+ * the commit the owner's copy runs, one after the other, in place, stopping at the first failure. Each state
1367
+ * write takes the owner's lease on its own; no lease is held while a deploy waits.
1368
+ */
1369
+ async runBuiltinFanOut(app) {
1370
+ const store = this.releaseStore;
1371
+ const platform = await this.platformDeps?.store.get();
1372
+ if (!store || !platform || !this.executor.deployBuiltinAt)
1373
+ return;
1374
+ const ownerOrgId = platform.ownerOrgId;
1375
+ const owner = await this.store.get(ownerOrgId);
1376
+ if (!owner)
1377
+ return;
1378
+ const empty = { orgId: ownerOrgId, steps: [], unmanaged: [] };
1379
+ if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
1380
+ await this.refusePlatform(empty, ownerOrgId, 'not authorized to run the release tick');
1381
+ throw new NotAuthorizedError(this.actor, ownerOrgId);
1382
+ }
1383
+ const deploy = this.executor.deployBuiltinAt.bind(this.executor);
1384
+ await runBuiltinFanOut({
1385
+ ownerOrgId,
1386
+ app,
1387
+ ownerDeclares: owner[app]?.blueGreen !== undefined,
1388
+ store,
1389
+ readIds: async () => ({
1390
+ ...((await this.store.getAppliedState(ownerOrgId))?.serviceIds ?? {}),
1391
+ }),
1392
+ write: (writes) => this.withMutationLease(companyScope(ownerOrgId), (reason) => this.refuse(empty, reason), async (fence) => {
1393
+ const previous = await this.store.getAppliedState(ownerOrgId);
1394
+ if (!previous)
1395
+ throw new Error('the owner org has no applied state');
1396
+ const serviceIds = { ...previous.serviceIds };
1397
+ for (const [key, value] of Object.entries(writes)) {
1398
+ if (value === null)
1399
+ delete serviceIds[key];
1400
+ else
1401
+ serviceIds[key] = value;
1402
+ }
1403
+ await this.store.putAppliedState({ ...previous, serviceIds }, fence);
1404
+ }),
1405
+ targets: async () => {
1406
+ const others = (await this.store.list()).filter((o) => o.id !== ownerOrgId && o[app] !== undefined);
1407
+ return others.map((o) => ({ orgId: o.id, slug: o.slug }));
1408
+ },
1409
+ // Re-reads the org fresh right before its own deploy, never from a list taken when the pass
1410
+ // started: a deploy can wait many minutes with no lease held on the target org (CLAUDE.md,
1411
+ // "Built-in fan-out"), so an ordinary `apply()` to a later-in-order org can turn its own
1412
+ // blue/green on in that window, and `deployBuiltinAt`'s guard must see that, not a stale copy.
1413
+ deploy: async (target, sha) => {
1414
+ const org = await this.store.get(target.orgId);
1415
+ if (!org || org[app] === undefined)
1416
+ return { ok: false, reason: 'org not found' };
1417
+ const recordedIds = (await this.store.getAppliedState(org.id))?.serviceIds ?? {};
1418
+ return deploy({ app, org, recordedIds, sha });
1419
+ },
1420
+ now: this.now,
1421
+ });
1422
+ }
1353
1423
  /**
1354
1424
  * Phase 2 for one org: takes the copy lock of each stage copy request under the org's lease, then runs the
1355
1425
  * copies after the lease is released. While a copy runs, every other tick and apply for the org goes on, a
@@ -68,6 +68,20 @@ export declare const PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY = "zitadel_plat
68
68
  export declare const AUTH_TUNNEL_ROUTES_VERSION = "2";
69
69
  /** The {@link AUTH_TUNNEL_ROUTES_VERSION} the sign-in tunnel was last written with. */
70
70
  export declare const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = "auth_tunnel_routes_applied";
71
+ /**
72
+ * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
73
+ * `LETTERMINT_API_TOKEN`). Raise it when a key is added or its meaning changes, so an applied
74
+ * sign-in plans its step once more and the apps get the env. Never holds a value.
75
+ * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
76
+ */
77
+ export declare const AUTH_LOGIN_ENV_VERSION = "1";
78
+ /**
79
+ * What the login apps' env is written for: the version and the sender address (`LOGIN_MAIL_FROM`).
80
+ * The address is no secret; a new mail domain gives a new marker, so the step is planned again.
81
+ */
82
+ export declare function authLoginEnvMarker(smtpSender: string): string;
83
+ /** The {@link authLoginEnvMarker} the login apps' env was last written with, once live on them. */
84
+ export declare const PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY = "auth_login_env_applied";
71
85
  /**
72
86
  * `'1'` once the broker holds `MCP_DATABASE_URL` and `MCP_OAUTH_SECRET` (auth ADR 0002), recorded by the
73
87
  * sign-in step when an entry of that broker declares `uses.auth.mcp`. A broker entry with `mcp` is not
@@ -977,6 +991,8 @@ export declare const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = "edge_platform_k
977
991
  */
978
992
  export declare const PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY = "edge_applied_hostname";
979
993
  export declare const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = "edge_applied_git_ref";
994
+ /** foundry#1024: what `provisionPlatformEdge` last wrote for {@link usageTailEdgeEnv}; see {@link usageTailEdgeEnvValue}. */
995
+ export declare const PLATFORM_EDGE_APPLIED_USAGE_TAIL_SCOPE_KEY = "edge_applied_usage_tail";
980
996
  /**
981
997
  * F4.dns: where edge answers on the Coolify network
982
998
  * (`http://<custom_internal_name>:3000`), recorded by the edge step once the
@@ -1206,6 +1222,45 @@ export declare const PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY = "uptime_access_
1206
1222
  export declare function uptimeAccessTokenConflict(): string;
1207
1223
  /** Whether anything of the uptime Worker is recorded that disabling it must remove. */
1208
1224
  export declare function uptimeRecorded(recordedIds: Readonly<Record<string, string>>): boolean;
1225
+ /**
1226
+ * foundry#1024: the usage tail Worker that meters every company Worker's
1227
+ * use, one `cloudflare:worker` step keyed {@link PLATFORM_USAGE_TAIL_KEY}.
1228
+ * `runPlatformSteps` records the script name under
1229
+ * `cloudflare:worker:platform:usage-tail` once it succeeds, which is what
1230
+ * stops `planUsageTail` planning it again.
1231
+ */
1232
+ export declare const PLATFORM_USAGE_TAIL_KEY = "platform:usage-tail";
1233
+ /** The Worker script's name at Cloudflare. */
1234
+ export declare const PLATFORM_USAGE_TAIL_SCRIPT_NAME = "platform-usage-tail";
1235
+ /**
1236
+ * The held ref of the Worker's one secret, `EDGE_USAGE_KEY` -- the narrow
1237
+ * `usage:ingest` credential edge's ingest route accepts. Minted once (rule
1238
+ * 1): the hash is never secret, so (the same pattern `provisionPlatformEdge`
1239
+ * uses for `EDGE_PLATFORM_KEY_HASH`) it doubles as both the undelivered
1240
+ * marker and the final recorded value, with nothing extra to compute or a
1241
+ * live read that could fall back to a blank value.
1242
+ */
1243
+ export declare const PLATFORM_USAGE_TAIL_KEY_REF = "usage-tail:worker:key";
1244
+ export declare const PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY = "usage_tail_key_undelivered";
1245
+ export declare const PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY = "usage_tail_key_set";
1246
+ export declare const PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY = "usage_tail_applied_tag";
1247
+ export declare const PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY = "usage_tail_applied_sha256";
1248
+ export declare const PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY = "usage_tail_removed";
1249
+ /**
1250
+ * What `platform:edge`'s own env should hold for the usage tail Worker
1251
+ * (`USAGE_TAIL_WORKER`, `EDGE_USAGE_KEY_HASH`), or `null` while the Worker is
1252
+ * not deployed or its key is not held yet. `provisionPlatformEdge` writes
1253
+ * these two names on every run alongside `EDGE_PLATFORM_KEY_HASH`; `planEdge`
1254
+ * reads {@link usageTailEdgeEnvValue} to replan edge once this changes.
1255
+ */
1256
+ export declare function usageTailEdgeEnv(recordedIds: Readonly<Record<string, string>>): {
1257
+ USAGE_TAIL_WORKER: string;
1258
+ EDGE_USAGE_KEY_HASH: string;
1259
+ } | null;
1260
+ /** The value `provisionPlatformEdge` records once it has written {@link usageTailEdgeEnv} to edge's env; empty while there is nothing to write. */
1261
+ export declare function usageTailEdgeEnvValue(recordedIds: Readonly<Record<string, string>>): string;
1262
+ /** Whether anything of the usage tail Worker is recorded that disabling it must remove. */
1263
+ export declare function usageTailRecorded(recordedIds: Readonly<Record<string, string>>): boolean;
1209
1264
  /**
1210
1265
  * Options for {@link planPlatform}, replacing four positional arguments that
1211
1266
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -1265,6 +1320,11 @@ export interface PlanPlatformOptions {
1265
1320
  * these. Default `[]`.
1266
1321
  */
1267
1322
  authOtherInputsMissing?: string[];
1323
+ /**
1324
+ * Whether the sealed Lettermint key resolved. `provisionAuthStack` records the login env marker only
1325
+ * then, so `planAuth` asks for the marker only when this is not false. Unset: asked for.
1326
+ */
1327
+ authMailKeyHeld?: boolean;
1268
1328
  /** C3: why the owner's invite cannot be made (no work address, no mail port). The rest of the step still plans. */
1269
1329
  authOwnerInvite?: string;
1270
1330
  files?: string;
@@ -1341,6 +1401,8 @@ export interface PlanPlatformOptions {
1341
1401
  pending: boolean;
1342
1402
  blocked?: string;
1343
1403
  };
1404
+ /** foundry#1024: why the usage tail Worker is blocked, or absent when it is not. */
1405
+ usageTail?: string;
1344
1406
  /** Why the organisation's release webhook step cannot run (the token is not held), or absent. */
1345
1407
  githubReleaseHook?: string;
1346
1408
  /**