@wtfalch/foundry 0.62.0 → 0.63.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 (75) hide show
  1. package/dist/bootstrap/declare.js +4 -1
  2. package/dist/bootstrap/versions.d.ts +2 -0
  3. package/dist/index.d.ts +6 -2
  4. package/dist/index.js +2 -1
  5. package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
  6. package/dist/migrations/0068_platform_registry.sql +17 -0
  7. package/dist/native-build.d.ts +7 -27
  8. package/dist/native-build.js +7 -27
  9. package/dist/plan/archon-blue-green.d.ts +5 -0
  10. package/dist/plan/archon-blue-green.js +2 -0
  11. package/dist/plan/blue-green-apply.d.ts +5 -0
  12. package/dist/plan/blue-green-apply.js +197 -22
  13. package/dist/plan/blue-green.d.ts +61 -2
  14. package/dist/plan/blue-green.js +80 -6
  15. package/dist/plan/boule.d.ts +1 -1
  16. package/dist/plan/boule.js +2 -0
  17. package/dist/plan/broker-entries.d.ts +9 -0
  18. package/dist/plan/broker-entries.js +29 -0
  19. package/dist/plan/diff.d.ts +5 -0
  20. package/dist/plan/diff.js +4 -0
  21. package/dist/plan/edge-access.d.ts +2 -3
  22. package/dist/plan/edge-access.js +7 -0
  23. package/dist/plan/executor.d.ts +71 -1
  24. package/dist/plan/executor.js +426 -24
  25. package/dist/plan/index.d.ts +2 -0
  26. package/dist/plan/index.js +1 -0
  27. package/dist/plan/jobs-scheduler-http.d.ts +2 -0
  28. package/dist/plan/jobs-scheduler-http.js +11 -0
  29. package/dist/plan/own-limits.d.ts +6 -0
  30. package/dist/plan/own-limits.js +1 -0
  31. package/dist/plan/plan-apply.d.ts +51 -1
  32. package/dist/plan/plan-apply.js +298 -25
  33. package/dist/plan/platform-diff.d.ts +6 -0
  34. package/dist/plan/platform-diff.js +74 -0
  35. package/dist/plan/platform-org.d.ts +2 -0
  36. package/dist/plan/platform-providers.d.ts +46 -1
  37. package/dist/plan/platform-providers.js +156 -1
  38. package/dist/plan/platform-registry-keys.d.ts +58 -0
  39. package/dist/plan/platform-registry-keys.js +69 -0
  40. package/dist/plan/platform-registry.d.ts +67 -0
  41. package/dist/plan/platform-registry.js +298 -0
  42. package/dist/plan/release-host.d.ts +31 -2
  43. package/dist/plan/release-host.js +114 -6
  44. package/dist/plan/stage-copy-host.d.ts +104 -0
  45. package/dist/plan/stage-copy-host.js +259 -0
  46. package/dist/plan/stage-copy.js +44 -0
  47. package/dist/plan/stage-env.d.ts +9 -0
  48. package/dist/plan/stage-env.js +38 -0
  49. package/dist/plan/stage-guards.d.ts +57 -1
  50. package/dist/plan/stage-guards.js +106 -0
  51. package/dist/plan/stage-isolation.js +56 -0
  52. package/dist/plan/stage-services.d.ts +93 -0
  53. package/dist/plan/stage-services.js +153 -0
  54. package/dist/plan/stage-signin.d.ts +152 -0
  55. package/dist/plan/stage-signin.js +133 -0
  56. package/dist/plan/stage-sink.d.ts +2 -0
  57. package/dist/plan/stage-sink.js +6 -1
  58. package/dist/platform.d.ts +7 -1
  59. package/dist/platform.js +1 -0
  60. package/dist/release/decide.d.ts +12 -0
  61. package/dist/release/decide.js +37 -2
  62. package/dist/release/run.d.ts +71 -1
  63. package/dist/release/run.js +92 -8
  64. package/dist/release/state.d.ts +14 -1
  65. package/dist/release/state.js +43 -0
  66. package/dist/release/types.d.ts +20 -0
  67. package/dist/release/types.js +2 -0
  68. package/dist/requested-changes.d.ts +11 -2
  69. package/dist/requested-changes.js +11 -0
  70. package/dist/store/postgres.js +8 -0
  71. package/dist/store/schema.d.ts +51 -0
  72. package/dist/store/schema.js +4 -0
  73. package/package.json +3 -2
  74. package/versions.digests.json +1 -0
  75. package/versions.json +1 -0
@@ -53,6 +53,7 @@ const SERVICES_ENABLED_BY_DEFAULT = new Set([
53
53
  'archon',
54
54
  'edge',
55
55
  'hc',
56
+ 'registry',
56
57
  ]);
57
58
  /**
58
59
  * The services every company deploys its own copy of. Their platform entries
@@ -153,7 +154,9 @@ export function declaredPlatformFor(apex, serverIpv4) {
153
154
  ? DEFAULT_MAIL_IMAGE_TAG
154
155
  : key === 'hc'
155
156
  ? DEFAULT_HC_IMAGE_TAG
156
- : DEFAULT_GIT_REF,
157
+ : key === 'registry'
158
+ ? VERSIONS.registry
159
+ : DEFAULT_GIT_REF,
157
160
  };
158
161
  }
159
162
  return {
@@ -12,6 +12,8 @@ export interface PinnedVersions {
12
12
  cloudflared: string;
13
13
  stalwart: string;
14
14
  hc: string;
15
+ /** The platform's image registry (`registry:3`, Docker's `distribution`). */
16
+ registry: string;
15
17
  /** The mail relay's base image (an exact Alpine release); Postfix comes from its package repository. */
16
18
  alpine: string;
17
19
  /** A static busybox, copied into the MAS image so its health check has an HTTP client. */
package/dist/index.d.ts CHANGED
@@ -15,8 +15,8 @@ export type { SlugRejectionReason, SlugRejection } from './slug.js';
15
15
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
16
16
  export type { Checkpoint, LastApplied, AppliedState, DestroyRecord, DestroyStepRecord, } from './state.js';
17
17
  export type { PlanStep, UnmanagedItem, PlanNote, Plan, VersionChangeItem } from './plan.js';
18
- export type { NativeBuildDispatchDeps, NativeBuildKeyEnvPort, } from './native-build.js';
19
- export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_REF, NATIVE_BUILD_DISPATCH_TIMEOUT_MS, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, NativeBuildDispatchError, dispatchNativeBuild, mintNativeBuildKey, } from './native-build.js';
18
+ export type { NativeBuildDispatchDeps } from './native-build.js';
19
+ export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_REF, NATIVE_BUILD_DISPATCH_TIMEOUT_MS, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, NativeBuildDispatchError, dispatchNativeBuild, } from './native-build.js';
20
20
  export type { AppliedVersion, AppliedVersionsStore, DeclaredOrgStore, HeldLease, MutationLease, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
21
21
  export { StaleLeaseError } from './store.js';
22
22
  export type { PlanApply, DestroyProgress } from './apply.js';
@@ -40,6 +40,8 @@ export { STAGE_COPY_EXCLUDED_TABLES, StageCopyFailedError, StageCopyRefusedError
40
40
  export type { StageCopyInput, StageCopyPort, StageCopyProduction, StageCopyRunRecord, UndoStageCopyInput, } from './plan/index.js';
41
41
  export { StageRestoreError, StageRestoreRefusedError, createStageCopyPort, execRunner, } from './plan/index.js';
42
42
  export type { Runner, StageCopyPortDeps } from './plan/index.js';
43
+ export type { PlatformRegistryDeps, PlatformRegistryRuntime } from './plan/index.js';
44
+ export { PLATFORM_REGISTRY_PULL_PASSWORD_REF, PLATFORM_REGISTRY_PUSH_PASSWORD_REF, PLATFORM_REGISTRY_PUSH_USER, platformRegistryPushCredential, } from './plan/index.js';
43
45
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
44
46
  export type { OrgPostgresMigrationConfirmation } from './plan/index.js';
45
47
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
@@ -59,6 +61,8 @@ export type { PlanOwnSfuOptions, SfuAppDeps, SfuAppInput } from './plan/index.js
59
61
  export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig, sfuCompose, } from './plan/index.js';
60
62
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, KeysRegistrarPort, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, LoginHostDiscoveryPort, } from './plan/index.js';
61
63
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
64
+ export type { StageProbePort, ReleasePreviewMembers } from './plan/stage-guards.js';
65
+ export type { StageCopyHostConfig } from './plan/stage-copy-host.js';
62
66
  export { NO_DOMAIN_ZONE, entryMachinePaths, ownHostnames, privatePageGateKey, workspaceHost, } from './plan/index.js';
63
67
  export type { PrivatePageScope } from './plan/index.js';
64
68
  export type { HostedLabelRefusal } from './hosted-label.js';
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ export const PINNED_VERSIONS = VERSIONS;
10
10
  export { buildJsonSchema, parseBuildJson, BuildJsonError } from './build-json.js';
11
11
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
12
12
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
13
- export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_REF, NATIVE_BUILD_DISPATCH_TIMEOUT_MS, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, NativeBuildDispatchError, dispatchNativeBuild, mintNativeBuildKey, } from './native-build.js';
13
+ export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_REF, NATIVE_BUILD_DISPATCH_TIMEOUT_MS, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, NativeBuildDispatchError, dispatchNativeBuild, } from './native-build.js';
14
14
  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';
@@ -24,6 +24,7 @@ export { DumpError, dumpNow } from './plan/index.js';
24
24
  export { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_IMAGE_REF, stageMailEnv, stageSinkAppName, } from './plan/index.js';
25
25
  export { STAGE_COPY_EXCLUDED_TABLES, StageCopyFailedError, StageCopyRefusedError, stageCopy, tableExcluded, undoStageCopy, } from './plan/index.js';
26
26
  export { StageRestoreError, StageRestoreRefusedError, createStageCopyPort, execRunner, } from './plan/index.js';
27
+ export { PLATFORM_REGISTRY_PULL_PASSWORD_REF, PLATFORM_REGISTRY_PUSH_PASSWORD_REF, PLATFORM_REGISTRY_PUSH_USER, platformRegistryPushCredential, } from './plan/index.js';
27
28
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
28
29
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
29
30
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: a company's Boule can request `kosmos` (turn `org.kosmos.enabled` on or off).
2
+ alter table foundry_requested_changes
3
+ drop constraint if exists foundry_requested_changes_field_check;
4
+ alter table foundry_requested_changes
5
+ add constraint foundry_requested_changes_field_check
6
+ check (field in ('calls', 'customers', 'storageLimit', 'aiLimit', 'domain', 'mailTier', 'app', 'build', 'bouleApps', 'kosmos'));
@@ -0,0 +1,17 @@
1
+ -- @wtfalch/foundry: the platform's image registry (`registry:3`), declared with the rest of the
2
+ -- platform. Mirrors `src/store/schema.ts`'s `foundryDeclaredPlatform` additions. Off for the
3
+ -- platform that already exists: foundry deploys nothing new until it is declared on. Backfills
4
+ -- `registry_hostname` the way `0047_platform_coolify.sql` backfilled `coolify_hostname`: the
5
+ -- column must not be empty (`src/platform.ts`'s `validatePlatform`).
6
+
7
+ alter table foundry_declared_platform
8
+ add column if not exists registry_enabled boolean not null default false,
9
+ add column if not exists registry_hostname text,
10
+ add column if not exists registry_git_ref text not null default '';
11
+
12
+ update foundry_declared_platform
13
+ set registry_hostname = 'registry.' || apex
14
+ where registry_hostname is null;
15
+
16
+ alter table foundry_declared_platform
17
+ alter column registry_hostname set not null;
@@ -7,15 +7,14 @@
7
7
  * workflow, and the key Boule presents when it asks the host (Archon) to
8
8
  * dispatch it.
9
9
  *
10
- * Neither function here is wired into `plan/diff.ts` or `plan/executor.ts`:
11
10
  * `dispatchNativeBuild` runs inside Archon's own `/api/native/builds`
12
- * route, not foundry's apply loop, and `mintNativeBuildKey` is left for the
13
- * host to call from wherever it writes Boule's env (`executor.ts`'s
14
- * `coolifyBoule`, the same way it already calls `mintIfAbsent` for
15
- * `BOULE_OWNER_SEED_SECRET_ENV`) once that wiring has its own review --
16
- * adding it here would touch every one of this package's Boule-convergence
17
- * fixtures for one new env var, which is a larger change than this issue
18
- * asks for.
11
+ * route, not foundry's apply loop. The key itself is minted by
12
+ * `plan/executor.ts`'s `coolifyBoule` (`ensureNativeBuildKey`), reusing the
13
+ * same `mintIfAbsent` path every other per-company Boule secret goes
14
+ * through (foundry#970) -- this file keeps only the env name and the byte
15
+ * length, not a second mint function (foundry#957 review note 2 and
16
+ * thermo note 1: the earlier `mintNativeBuildKey` was a third, buggy copy
17
+ * of that same check).
19
18
  */
20
19
  export declare const NATIVE_BUILD_REPO = "wtfalch/shell";
21
20
  export declare const NATIVE_BUILD_WORKFLOW_FILE = "build-app.yml";
@@ -49,22 +48,3 @@ export declare function dispatchNativeBuild(deps: NativeBuildDispatchDeps, run:
49
48
  /** The env name Boule presents this key under, to Archon's native-build route. */
50
49
  export declare const NATIVE_BUILD_KEY_ENV = "NATIVE_BUILD_KEY";
51
50
  export declare const NATIVE_BUILD_KEY_BYTES = 32;
52
- /** The one thing {@link mintNativeBuildKey} needs from Coolify: a live app's env, to check whether it already has a key. */
53
- export interface NativeBuildKeyEnvPort {
54
- listEnvs(appId: string): Promise<ReadonlyArray<{
55
- key: string;
56
- value: string;
57
- is_preview: boolean;
58
- }>>;
59
- }
60
- /**
61
- * Mints the per-company key Boule presents to Archon's native-build route
62
- * (estate#262), the same way every other per-company key in this codebase is
63
- * minted (estate#207): a random value, written to Boule's env once and never
64
- * rotated, kept unchanged on every later apply once it is there. Returns
65
- * `null` when Boule's env already holds one -- the caller then writes
66
- * nothing, so the live value stands. `onSecret`, when given, is told the
67
- * minted value so the caller can keep it out of its own logs and error
68
- * messages.
69
- */
70
- export declare function mintNativeBuildKey(coolify: NativeBuildKeyEnvPort, appId: string, onSecret?: (value: string) => void): Promise<string | null>;
@@ -1,4 +1,3 @@
1
- import { randomBytes } from 'node:crypto';
2
1
  /**
3
2
  * `wtfalch/shell`'s `build-app.yml`: the one GitHub Actions workflow that
4
3
  * builds and signs a company's native app shell (estate
@@ -8,15 +7,14 @@ import { randomBytes } from 'node:crypto';
8
7
  * workflow, and the key Boule presents when it asks the host (Archon) to
9
8
  * dispatch it.
10
9
  *
11
- * Neither function here is wired into `plan/diff.ts` or `plan/executor.ts`:
12
10
  * `dispatchNativeBuild` runs inside Archon's own `/api/native/builds`
13
- * route, not foundry's apply loop, and `mintNativeBuildKey` is left for the
14
- * host to call from wherever it writes Boule's env (`executor.ts`'s
15
- * `coolifyBoule`, the same way it already calls `mintIfAbsent` for
16
- * `BOULE_OWNER_SEED_SECRET_ENV`) once that wiring has its own review --
17
- * adding it here would touch every one of this package's Boule-convergence
18
- * fixtures for one new env var, which is a larger change than this issue
19
- * asks for.
11
+ * route, not foundry's apply loop. The key itself is minted by
12
+ * `plan/executor.ts`'s `coolifyBoule` (`ensureNativeBuildKey`), reusing the
13
+ * same `mintIfAbsent` path every other per-company Boule secret goes
14
+ * through (foundry#970) -- this file keeps only the env name and the byte
15
+ * length, not a second mint function (foundry#957 review note 2 and
16
+ * thermo note 1: the earlier `mintNativeBuildKey` was a third, buggy copy
17
+ * of that same check).
20
18
  */
21
19
  export const NATIVE_BUILD_REPO = 'wtfalch/shell';
22
20
  export const NATIVE_BUILD_WORKFLOW_FILE = 'build-app.yml';
@@ -72,21 +70,3 @@ export async function dispatchNativeBuild(deps, run) {
72
70
  /** The env name Boule presents this key under, to Archon's native-build route. */
73
71
  export const NATIVE_BUILD_KEY_ENV = 'NATIVE_BUILD_KEY';
74
72
  export const NATIVE_BUILD_KEY_BYTES = 32;
75
- /**
76
- * Mints the per-company key Boule presents to Archon's native-build route
77
- * (estate#262), the same way every other per-company key in this codebase is
78
- * minted (estate#207): a random value, written to Boule's env once and never
79
- * rotated, kept unchanged on every later apply once it is there. Returns
80
- * `null` when Boule's env already holds one -- the caller then writes
81
- * nothing, so the live value stands. `onSecret`, when given, is told the
82
- * minted value so the caller can keep it out of its own logs and error
83
- * messages.
84
- */
85
- export async function mintNativeBuildKey(coolify, appId, onSecret) {
86
- const envs = await coolify.listEnvs(appId);
87
- if (envs.some((e) => e.key === NATIVE_BUILD_KEY_ENV && !e.is_preview))
88
- return null;
89
- const value = randomBytes(NATIVE_BUILD_KEY_BYTES).toString('base64url');
90
- onSecret?.(value);
91
- return value;
92
- }
@@ -2,6 +2,7 @@ import { type BlueGreenApplyDeps } from './blue-green-apply.js';
2
2
  import { type BlueGreenSubject } from './blue-green.js';
3
3
  import type { DeployHealthRuntime } from './deploy-health.js';
4
4
  import type { PlatformTunnelPort } from './platform-providers.js';
5
+ import type { StageGuardDeps } from './stage-guards.js';
5
6
  export interface ArchonBlueGreenRun {
6
7
  subject: BlueGreenSubject;
7
8
  /** The owner company's id: the health token's held ref is `sharedSecretRef(ownerOrgId, ...)` like an entry's. */
@@ -42,6 +43,10 @@ export interface ArchonBlueGreenRun {
42
43
  sleep: (ms: number) => Promise<void>;
43
44
  logDir: string;
44
45
  isRunning: (status: string | undefined) => boolean;
46
+ /** Stage's probe port and Access gate, the same guards an entry's stage gets. Absent: the guards read "not run". */
47
+ stageGuards?: StageGuardDeps;
48
+ /** The guard names the plan counted (`desired.guards`); they go into the applied hash. */
49
+ guardInputs?: readonly string[];
45
50
  }
46
51
  /**
47
52
  * platform-archon on the blue/green path (`platform.archon.blueGreen`): the archon app keeps its name and is
@@ -70,6 +70,8 @@ export async function applyArchonBlueGreen(run) {
70
70
  generate: () => randomBytes(32).toString('hex'),
71
71
  secret: run.secret,
72
72
  }),
73
+ ...(run.stageGuards ? { stageGuards: run.stageGuards } : {}),
74
+ ...(run.guardInputs ? { guardInputs: run.guardInputs } : {}),
73
75
  actionsKey: async () => {
74
76
  const key = await mintActionsKey({
75
77
  id,
@@ -54,6 +54,11 @@ export interface BlueGreenApplyDeps {
54
54
  * guard unrecorded ("not run", never passed), so `stageGuardsReady` stays false and the copy does not start.
55
55
  */
56
56
  stageGuards?: StageGuardDeps;
57
+ /**
58
+ * The guard names the plan counted ({@link stageGuardInputs}, from `desired.guards`). They go into the
59
+ * applied structure hash, so the next plan sees the same hash until a guard becomes possible.
60
+ */
61
+ guardInputs?: readonly string[];
57
62
  /**
58
63
  * The company the stage mail sink belongs to (its name is `<slug>-stage-sink`). Absent: no sink is made and
59
64
  * stage holds no mail env at all, so the copy stays refused.
@@ -10,6 +10,8 @@ import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js'
10
10
  import { STAGE_SIGN_IN_NAMES, buildStageEnv } from './stage-env.js';
11
11
  import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
12
12
  import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
13
+ import { StageServiceError, ensureStageServices } from './stage-services.js';
14
+ import { STAGE_SIGNIN_NONE, StageSignInError, ensureStagePublicClient, ensureStageSignIn, } from './stage-signin.js';
13
15
  import { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_APP_SCOPE_KEY, STAGE_SINK_HTTP_PORT, STAGE_SINK_IMAGE_TAG, STAGE_SINK_REPOSITORY, stageMailEnv, stageSinkAppName, } from './stage-sink.js';
14
16
  import { readWorkspaceCommit, wsKey } from './workspace.js';
15
17
  /** How long one Coolify call of the sink may take. */
@@ -95,7 +97,10 @@ export async function applyBlueGreenServer(deps, subject, server) {
95
97
  await deps.persist();
96
98
  }
97
99
  }
98
- deps.record(bgKey.applied(id, server.uuid), structureHash(subject, { primary: isPrimary }));
100
+ deps.record(bgKey.applied(id, server.uuid), structureHash(subject, {
101
+ primary: isPrimary,
102
+ ...(deps.guardInputs ? { guards: deps.guardInputs } : {}),
103
+ }));
99
104
  await deps.persist();
100
105
  }
101
106
  /**
@@ -481,30 +486,92 @@ export async function ensureStage(deps, subject, serverUuid) {
481
486
  const stageHas = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
482
487
  const mailChanged = [...mail, ...signInEnv].some((e) => stageHas.get(e.key) !== e.value);
483
488
  stageEnv.push(...mail);
484
- const stageSurface = {
489
+ const stageSurfaceOf = () => ({
485
490
  env: stageEnv.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
486
491
  resources: stageResources(subject),
492
+ });
493
+ const prodSurface = {
494
+ env: [...prodEnv, ...greenEnv]
495
+ .filter((e) => !e.is_preview && e.value !== undefined)
496
+ .map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
497
+ resources: prodResources(subject),
498
+ internalHosts: Object.values(prodResources(subject)),
487
499
  };
488
- const violations = assertStageIsolated({
500
+ const sinkMail = Object.fromEntries(mail.map((e) => [e.key, e.value]));
501
+ const signInIsolation = deps.signIn
502
+ ? { signIn: { customers: deps.signIn.customers, live: deps.signIn.live } }
503
+ : {};
504
+ const isolationViolations = (surface) => assertStageIsolated({
489
505
  kind,
490
- prod: {
491
- env: [...prodEnv, ...greenEnv]
492
- .filter((e) => !e.is_preview && e.value !== undefined)
493
- .map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
494
- resources: prodResources(subject),
495
- internalHosts: Object.values(prodResources(subject)),
496
- },
497
- stage: stageSurface,
498
- sinkMail: Object.fromEntries(mail.map((e) => [e.key, e.value])),
499
- ...(deps.signIn
500
- ? { signIn: { customers: deps.signIn.customers, live: deps.signIn.live } }
501
- : {}),
506
+ prod: prodSurface,
507
+ stage: surface,
508
+ sinkMail,
509
+ ...signInIsolation,
502
510
  });
511
+ // The env written this run, and the env stage already holds (put there by an earlier apply, an older
512
+ // builder or a hand): a forbidden name or a production value already on the live stage app is refused too.
513
+ // A name this run writes again is replaced, so only the other live names are checked here.
514
+ const written = new Set(stageEnv.map((e) => e.key));
515
+ const liveStage = {
516
+ env: [...stageHas]
517
+ .filter(([name, value]) => value !== undefined && !written.has(name))
518
+ .map(([name, value]) => ({ name, class: envClass(name), value })),
519
+ resources: {},
520
+ };
521
+ let stageSurface = stageSurfaceOf();
522
+ const violations = [
523
+ ...new Set([
524
+ ...isolationViolations(stageSurface),
525
+ ...assertStageIsolated({
526
+ kind,
527
+ prod: prodSurface,
528
+ stage: liveStage,
529
+ sinkMail,
530
+ ...signInIsolation,
531
+ }),
532
+ ]),
533
+ ].sort();
503
534
  if (violations.length > 0) {
504
535
  // Stage fails closed for itself: nothing is written to it, production's structure goes on.
505
536
  await stageFailed(deps, subject, `blocked:${violations.join(',')}`);
506
537
  return;
507
538
  }
539
+ // Stage's own files, ai and jobs organisations and keys, for the services production's app uses. They are made
540
+ // before the probes, which need the keys; nothing is written to the stage app until the probes were rejected.
541
+ const prodValues = new Map(prodEnv
542
+ .filter((e) => !e.is_preview && e.value !== undefined)
543
+ .map((e) => [e.key, e.value]));
544
+ let servicesRecord;
545
+ try {
546
+ const made = await ensureStageServices({
547
+ id: subject.id,
548
+ kind,
549
+ prod: prodValues,
550
+ port: deps.stageGuards?.services,
551
+ recorded: deps.recorded,
552
+ record: deps.record,
553
+ persist: deps.persist,
554
+ secret: deps.secret,
555
+ log: (line) => console.info(`${deps.label}: ${line}`),
556
+ });
557
+ stageEnv.push(...made.env);
558
+ servicesRecord = made.record;
559
+ }
560
+ catch (error) {
561
+ // A StageServiceError holds ids and refs only; any other cause can echo a credential.
562
+ if (error instanceof StageServiceError)
563
+ console.error(`${deps.label}: ${error.message}`);
564
+ await stageFailed(deps, subject, 'blocked:services', error);
565
+ return;
566
+ }
567
+ if (servicesRecord !== 'none') {
568
+ stageSurface = stageSurfaceOf();
569
+ const withServices = isolationViolations(stageSurface);
570
+ if (withServices.length > 0) {
571
+ await stageFailed(deps, subject, `blocked:${withServices.join(',')}`);
572
+ return;
573
+ }
574
+ }
508
575
  // Stage's credentials are tried against production before they are written; one that production accepts
509
576
  // blocks stage (nothing is written to it), and what was tried is recorded by name so the copy slice sees it.
510
577
  const stageValues = new Map((await coolify.listEnvs(appId))
@@ -512,7 +579,7 @@ export async function ensureStage(deps, subject, serverUuid) {
512
579
  .map((e) => [e.key, e.value]));
513
580
  for (const e of stageEnv)
514
581
  stageValues.set(e.key, e.value);
515
- const isolationHash = stageIsolationHash(stageSurface);
582
+ let isolationHash = stageIsolationHash(stageSurface);
516
583
  let probeResult;
517
584
  try {
518
585
  probeResult = await runStageProbes(buildStageProbes({
@@ -524,9 +591,12 @@ export async function ensureStage(deps, subject, serverUuid) {
524
591
  ? { liveCustomersOrgId: deps.signIn.live.customersOrgId }
525
592
  : {}),
526
593
  ...(deps.signIn ? { signInRedirectUri: deps.signIn.redirectUri } : {}),
527
- prod: new Map(prodEnv
528
- .filter((e) => !e.is_preview && e.value !== undefined)
529
- .map((e) => [e.key, e.value])),
594
+ prod: new Map([
595
+ ...prodValues,
596
+ ...(deps.stageGuards?.services?.prodJobsProjectId
597
+ ? [['JOBS_PROJECT_ID', deps.stageGuards.services.prodJobsProjectId]]
598
+ : []),
599
+ ]),
530
600
  }));
531
601
  }
532
602
  catch (error) {
@@ -534,7 +604,7 @@ export async function ensureStage(deps, subject, serverUuid) {
534
604
  await stageFailed(deps, subject, 'blocked:database', error);
535
605
  return;
536
606
  }
537
- const probes = stageProbesRecord(probeResult, isolationHash, stageValues);
607
+ let probes = stageProbesRecord(probeResult, isolationHash, stageValues);
538
608
  if (probeResult.failed.length > 0) {
539
609
  deps.record(bgKey.stageProbes(subject.id), probes.value);
540
610
  await stageFailed(deps, subject, `blocked:${probeResult.failed.join(',')}`);
@@ -544,9 +614,108 @@ export async function ensureStage(deps, subject, serverUuid) {
544
614
  await stageFailed(deps, subject, 'blocked:gate');
545
615
  return;
546
616
  }
617
+ // Stage's own sign-in: made only now, after the isolation check, the probes and the gate. Its credentials are
618
+ // added to the env and the isolation check runs once more over the whole of it.
619
+ const signIn = deps.stageGuards?.signIn;
620
+ let signInGuard;
621
+ if (signIn?.required === true && signIn.public === true) {
622
+ // A static SPA (Agora): a client and nothing secret. Its config is built from production's with the stage
623
+ // client id, then the isolation check runs once more over the whole env.
624
+ try {
625
+ const made = await ensureStagePublicClient({
626
+ id: subject.id,
627
+ port: signIn,
628
+ recorded: deps.recorded,
629
+ record: deps.record,
630
+ persist: deps.persist,
631
+ });
632
+ const rebuilt = buildStageEnv({
633
+ kind,
634
+ appUrl: stageAppUrl(subject),
635
+ logDir: deps.logDir,
636
+ database,
637
+ prod: prodEnv,
638
+ existing: [],
639
+ mint: () => '',
640
+ stageClientId: made.clientId,
641
+ });
642
+ const config = rebuilt.env.find((e) => e.key === 'AGORA_CONFIG');
643
+ if (config)
644
+ stageEnv.push(config);
645
+ signInGuard = made.guard;
646
+ }
647
+ catch (error) {
648
+ if (error instanceof StageSignInError)
649
+ console.error(`${deps.label}: ${error.message}`);
650
+ deps.record(bgKey.stageSignIn(subject.id), '');
651
+ await stageFailed(deps, subject, 'blocked:signin', error);
652
+ return;
653
+ }
654
+ for (const e of stageEnv)
655
+ stageValues.set(e.key, e.value);
656
+ stageSurface = stageSurfaceOf();
657
+ const again = isolationViolations(stageSurface);
658
+ if (again.length > 0) {
659
+ deps.record(bgKey.stageSignIn(subject.id), '');
660
+ await stageFailed(deps, subject, `blocked:${again.join(',')}`);
661
+ return;
662
+ }
663
+ isolationHash = stageIsolationHash(stageSurface);
664
+ probes = stageProbesRecord(probeResult, isolationHash, stageValues);
665
+ }
666
+ else if (signIn?.required === true) {
667
+ try {
668
+ const made = await ensureStageSignIn({
669
+ id: subject.id,
670
+ port: signIn,
671
+ recorded: deps.recorded,
672
+ record: deps.record,
673
+ persist: deps.persist,
674
+ secret: deps.secret,
675
+ generate: () => randomBytes(32).toString('base64url'),
676
+ sleep: deps.sleep,
677
+ });
678
+ stageEnv.push(...made.env);
679
+ signInGuard = made.guard;
680
+ }
681
+ catch (error) {
682
+ // A StageSignInError holds ids and refs only; any other cause can echo a credential.
683
+ if (error instanceof StageSignInError)
684
+ console.error(`${deps.label}: ${error.message}`);
685
+ deps.record(bgKey.stageSignIn(subject.id), '');
686
+ await stageFailed(deps, subject, 'blocked:signin', error);
687
+ return;
688
+ }
689
+ if (!stageEnv.some((e) => e.key === 'AUTH_COOKIE_SECRET')) {
690
+ // A cookie secret of stage's own, kept across applies; never a copy of production's.
691
+ const have = stageHas.get('AUTH_COOKIE_SECRET') || randomBytes(32).toString('base64url');
692
+ deps.secret(have);
693
+ stageEnv.push({ key: 'AUTH_COOKIE_SECRET', value: have });
694
+ }
695
+ for (const e of stageEnv)
696
+ stageValues.set(e.key, e.value);
697
+ stageSurface = stageSurfaceOf();
698
+ const again = isolationViolations(stageSurface);
699
+ if (again.length > 0) {
700
+ deps.record(bgKey.stageSignIn(subject.id), '');
701
+ await stageFailed(deps, subject, `blocked:${again.join(',')}`);
702
+ return;
703
+ }
704
+ isolationHash = stageIsolationHash(stageSurface);
705
+ probes = stageProbesRecord(probeResult, isolationHash, stageValues);
706
+ }
707
+ else if (signIn?.required === false) {
708
+ signInGuard = STAGE_SIGNIN_NONE;
709
+ }
710
+ else {
711
+ console.info(`${deps.label}: stage sign-in was not set up: the host gave no sign-in for this subject, so ${bgKey.stageSignIn(subject.id)} stays unrecorded`);
712
+ }
547
713
  await upsertEnv(deps, appId, stageEnv);
714
+ if (signInGuard)
715
+ deps.record(bgKey.stageSignIn(subject.id), signInGuard);
548
716
  if (mail.length > 0)
549
717
  deps.record(bgKey.stageSink(subject.id), 'applied');
718
+ deps.record(bgKey.stageServices(subject.id), servicesRecord);
550
719
  deps.record(bgKey.stageIsolation(subject.id), isolationHash);
551
720
  deps.record(bgKey.stageProbes(subject.id), probes.value);
552
721
  await deps.persist();
@@ -589,8 +758,11 @@ async function ensureStageGate(deps, subject) {
589
758
  if (!gate || deps.recorded()[bgKey.stageGate(subject.id)])
590
759
  return true;
591
760
  let id;
761
+ let membersHash;
592
762
  try {
593
- id = (await gate.create()).id;
763
+ const created = await gate.create();
764
+ id = created.id;
765
+ membersHash = created.membersHash;
594
766
  }
595
767
  catch (error) {
596
768
  // An Access error can echo the request, which names the people allowed in: only its name is logged.
@@ -598,6 +770,8 @@ async function ensureStageGate(deps, subject) {
598
770
  return false;
599
771
  }
600
772
  deps.record(bgKey.stageGate(subject.id), id);
773
+ if (membersHash)
774
+ deps.record(bgKey.stageGateMembers(subject.id), membersHash);
601
775
  await deps.persist();
602
776
  return true;
603
777
  }
@@ -607,7 +781,8 @@ async function stageAppId(deps, subject, name, serverUuid) {
607
781
  name,
608
782
  server: serverUuid,
609
783
  branch: subject.branch,
610
- autoDeploy: true,
784
+ // Stage deploys only when a person promotes a commit to it (`deploy_stage`); a merge never reaches it.
785
+ autoDeploy: false,
611
786
  });
612
787
  }
613
788
  function isNotFound(error) {
@@ -29,6 +29,8 @@ export declare const bgKey: {
29
29
  digest: (id: string, color: Color) => string;
30
30
  weights: (id: string) => string;
31
31
  stage: (id: string) => string;
32
+ /** The last `deploy_stage`: `<ISO time>|ok|<commit>` or `<ISO time>|failed|<step>`. */
33
+ stageDeploy: (id: string) => string;
32
34
  window: (id: string) => string;
33
35
  held: (id: string, digest: string) => string;
34
36
  /** The weights the load balancer on `server` was last written with. */
@@ -55,8 +57,29 @@ export declare const bgKey: {
55
57
  stageIsolation: (id: string) => string;
56
58
  /** The id of the Access gate over the stage host: staff only, no bypass, no service-token rule. */
57
59
  stageGate: (id: string) => string;
60
+ /** The hash of the sorted email list last written to the stage gate (owners plus the people holding `releases:preview`). */
61
+ stageGateMembers: (id: string) => string;
58
62
  /** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
59
63
  stageProbes: (id: string) => string;
64
+ /** Stage's own sign-in client: its ZITADEL app id, and its client id (public, not a secret). */
65
+ stageClient: (id: string) => string;
66
+ stageClientId: (id: string) => string;
67
+ /** The held ref of stage's app key, recorded and persisted as soon as it is minted (no `:`: undelivered). */
68
+ stageSignInKey: (id: string) => string;
69
+ /** `{name, organizationId}` of stage's broker entry, so org destroy can remove it. No key. */
70
+ stageBroker: (id: string) => string;
71
+ /** `ok:<hash of the client, app and broker entry ids>` or `none`; the copy waits for it. */
72
+ stageSignIn: (id: string) => string;
73
+ /** `none` (production's app uses no files, ai or jobs) or `ok:<hash>` once every service it uses is held by stage or listed absent. */
74
+ stageServices: (id: string) => string;
75
+ /** `ok:<hash of the ids>` or `absent:<service>:<reason>` for one service (`files`, `ai` or `jobs`). */
76
+ stageService: (id: string, service: string) => string;
77
+ /** The id of stage's own organisation (files, ai) or project (jobs). An id, not a secret. */
78
+ stageServiceOrg: (id: string, service: string) => string;
79
+ /** The name stage's project or organisation was made under, when the service is deleted by name (jobs). */
80
+ stageServiceName: (id: string, service: string) => string;
81
+ /** `{ids, refs}` of stage's keys in that service: key ids and held refs, never a value. */
82
+ stageServiceKeys: (id: string, service: string) => string;
60
83
  /** `applied` once the stage app holds the sink's mail env. The copy refuses without it. */
61
84
  stageSink: (id: string) => string;
62
85
  /** `<ISO time>|<dump execution id>|ok`, written by a finished copy of production into stage. */
@@ -293,9 +316,38 @@ export interface BlueGreenPlanStep {
293
316
  /** A short hash of what decides the shape of a server's apps, so a change replans the server. */
294
317
  export declare function structureHash(subject: BlueGreenSubject, input: {
295
318
  primary: boolean;
319
+ guards?: readonly string[];
296
320
  }): string;
297
- /** The kind of stage app a subject has: platform-archon, else a customer app. */
298
- export declare const stageKindOf: (subject: Pick<BlueGreenSubject, "id">) => StageKind;
321
+ /**
322
+ * The kind of stage app a subject has: Boule and Agora by `builtin` (a hosting entry that happens to be keyed
323
+ * `boule` is still a customer app), platform-archon by its id, else a customer app (kosmos included: it has no lists of its own).
324
+ */
325
+ export declare const stageKindOf: (subject: Pick<BlueGreenSubject, "id" | "builtin">) => StageKind;
326
+ /** What decides which stage guards can be made. Each is a fact the planner and the executor both hold. */
327
+ export interface StageGuardFacts {
328
+ /** The host supplied the probe port. */
329
+ probesPort: boolean;
330
+ /** The host has an Access port. */
331
+ accessPort: boolean;
332
+ /** The platform's recorded Access provider. */
333
+ providerId?: string | null | undefined;
334
+ /** The domain the stage host is under. */
335
+ domain?: string | null | undefined;
336
+ /** The company's sign-in organisation, the claim the gate requires. */
337
+ claimValue?: string | null | undefined;
338
+ /** How many owner emails the gate admits. */
339
+ emails: number;
340
+ /** A `hosting.access` rule already names the stage host: no gate is made over it. */
341
+ hostRuled: boolean;
342
+ }
343
+ /**
344
+ * The stage guards that can be made now, by name. The names go into the applied structure hash, so a guard
345
+ * that becomes possible after the first apply changes the hash and plans the structure step once more.
346
+ */
347
+ export declare function stageGuardInputs(facts: StageGuardFacts): string[];
348
+ /** The guard names as the plan's `desired.guards` carries them, and back. */
349
+ export declare const formatGuards: (guards: readonly string[]) => string;
350
+ export declare const parseGuards: (value: unknown) => string[];
299
351
  /** The names stage can collide on. */
300
352
  export declare function stageResources(subject: BlueGreenSubject): Record<string, string>;
301
353
  /** The names of the subject's live parts: stage must equal none of them. */
@@ -322,6 +374,11 @@ export interface PlanBlueGreenInput {
322
374
  accessProviderId?: string | null;
323
375
  /** The company's stage mail sink is recorded and its live app is stopped or gone (read from Coolify at plan time). */
324
376
  stageSinkDown?: boolean;
377
+ /** Which stage-guard ports the host has. Absent: neither, so no guard input is counted. */
378
+ stageGuardPorts?: {
379
+ probes: boolean;
380
+ access: boolean;
381
+ };
325
382
  }
326
383
  /**
327
384
  * Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
@@ -371,6 +428,8 @@ export declare function planArchonBlueGreen(input: {
371
428
  servers: readonly BlueGreenServer[] | undefined;
372
429
  /** Why the executor cannot run it (no way to hold the health token), or absent. */
373
430
  executorBlocked?: string | undefined;
431
+ /** The stage guards that can be made now ({@link stageGuardInputs}); they go into the applied hash. */
432
+ guards?: readonly string[];
374
433
  }): {
375
434
  step: BlueGreenPlanStep | null;
376
435
  unmanaged: UnmanagedLine[];