@wtfalch/foundry 0.59.0 → 0.61.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 (218) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +10 -5
  44. package/dist/index.js +5 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/modules/ai.d.ts +3 -0
  49. package/dist/modules/ai.js +64 -0
  50. package/dist/modules/assistants.d.ts +3 -0
  51. package/dist/modules/assistants.js +72 -0
  52. package/dist/modules/audit-ledger.d.ts +29 -0
  53. package/dist/modules/audit-ledger.js +49 -0
  54. package/dist/modules/audit.d.ts +7 -0
  55. package/dist/modules/audit.js +44 -0
  56. package/dist/modules/database.d.ts +23 -0
  57. package/dist/modules/database.js +189 -0
  58. package/dist/modules/email.d.ts +3 -0
  59. package/dist/modules/email.js +18 -0
  60. package/dist/modules/entry-parts.d.ts +23 -0
  61. package/dist/modules/entry-parts.js +63 -0
  62. package/dist/modules/files.d.ts +3 -0
  63. package/dist/modules/files.js +64 -0
  64. package/dist/modules/index.d.ts +74 -0
  65. package/dist/modules/index.js +774 -0
  66. package/dist/modules/jobs.d.ts +3 -0
  67. package/dist/modules/jobs.js +23 -0
  68. package/dist/modules/organisations.d.ts +3 -0
  69. package/dist/modules/organisations.js +20 -0
  70. package/dist/modules/outputs.d.ts +9 -0
  71. package/dist/modules/outputs.js +38 -0
  72. package/dist/modules/sign-in.d.ts +3 -0
  73. package/dist/modules/sign-in.js +197 -0
  74. package/dist/modules/signed-records.d.ts +3 -0
  75. package/dist/modules/signed-records.js +32 -0
  76. package/dist/modules/types.d.ts +194 -0
  77. package/dist/modules/types.js +1 -0
  78. package/dist/modules/workspaces.d.ts +3 -0
  79. package/dist/modules/workspaces.js +75 -0
  80. package/dist/org.d.ts +163 -22
  81. package/dist/org.js +419 -47
  82. package/dist/plan/apps-db-route.d.ts +90 -0
  83. package/dist/plan/apps-db-route.js +168 -0
  84. package/dist/plan/apps-zones.d.ts +56 -0
  85. package/dist/plan/apps-zones.js +118 -0
  86. package/dist/plan/archon-blue-green.js +11 -1
  87. package/dist/plan/blue-green-apply.d.ts +37 -1
  88. package/dist/plan/blue-green-apply.js +55 -15
  89. package/dist/plan/blue-green.d.ts +41 -1
  90. package/dist/plan/blue-green.js +80 -2
  91. package/dist/plan/boule.d.ts +23 -1
  92. package/dist/plan/boule.js +35 -2
  93. package/dist/plan/broker-entries.d.ts +28 -3
  94. package/dist/plan/broker-entries.js +74 -3
  95. package/dist/plan/broker-registrar.d.ts +168 -0
  96. package/dist/plan/broker-registrar.js +440 -0
  97. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  98. package/dist/plan/broker-workspaces-database.js +87 -0
  99. package/dist/plan/desktop-callback.js +12 -7
  100. package/dist/plan/diff.d.ts +8 -2
  101. package/dist/plan/diff.js +373 -42
  102. package/dist/plan/edge-access.d.ts +6 -0
  103. package/dist/plan/edge-dns.d.ts +6 -0
  104. package/dist/plan/edge-dns.js +9 -7
  105. package/dist/plan/entry-ai-key.d.ts +1 -1
  106. package/dist/plan/entry-ai-key.js +2 -2
  107. package/dist/plan/entry-audit.d.ts +58 -0
  108. package/dist/plan/entry-audit.js +122 -0
  109. package/dist/plan/entry-customer-grants.d.ts +75 -0
  110. package/dist/plan/entry-customer-grants.js +154 -0
  111. package/dist/plan/entry-edge-key.d.ts +26 -1
  112. package/dist/plan/entry-edge-key.js +42 -3
  113. package/dist/plan/entry-jobs.d.ts +64 -0
  114. package/dist/plan/entry-jobs.js +92 -0
  115. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  116. package/dist/plan/entry-repo-key-apply.js +185 -0
  117. package/dist/plan/entry-repo-key.d.ts +92 -0
  118. package/dist/plan/entry-repo-key.js +235 -0
  119. package/dist/plan/entry-workspaces.d.ts +55 -0
  120. package/dist/plan/entry-workspaces.js +90 -0
  121. package/dist/plan/executor.d.ts +191 -1
  122. package/dist/plan/executor.js +1505 -90
  123. package/dist/plan/gates.d.ts +1 -1
  124. package/dist/plan/gates.js +62 -9
  125. package/dist/plan/github-deploy-keys.d.ts +78 -0
  126. package/dist/plan/github-deploy-keys.js +305 -0
  127. package/dist/plan/github-source.d.ts +53 -0
  128. package/dist/plan/github-source.js +140 -0
  129. package/dist/plan/held-key-refs.d.ts +12 -0
  130. package/dist/plan/held-key-refs.js +12 -0
  131. package/dist/plan/hostnames.d.ts +9 -0
  132. package/dist/plan/hostnames.js +28 -1
  133. package/dist/plan/index.d.ts +9 -0
  134. package/dist/plan/index.js +6 -0
  135. package/dist/plan/jobs-scheduler-http.js +3 -0
  136. package/dist/plan/keys-registrar-http.d.ts +32 -0
  137. package/dist/plan/keys-registrar-http.js +105 -0
  138. package/dist/plan/keys-registrar.d.ts +67 -0
  139. package/dist/plan/keys-registrar.js +103 -0
  140. package/dist/plan/live.d.ts +1 -1
  141. package/dist/plan/locked-hostnames.d.ts +2 -2
  142. package/dist/plan/locked-hostnames.js +2 -2
  143. package/dist/plan/login-host.js +11 -5
  144. package/dist/plan/machine-paths.d.ts +36 -0
  145. package/dist/plan/machine-paths.js +137 -0
  146. package/dist/plan/mcp.d.ts +1 -1
  147. package/dist/plan/mcp.js +49 -17
  148. package/dist/plan/org-apps-admin.d.ts +108 -0
  149. package/dist/plan/org-apps-admin.js +434 -0
  150. package/dist/plan/org-names.js +12 -1
  151. package/dist/plan/own-diff.d.ts +23 -0
  152. package/dist/plan/own-diff.js +53 -0
  153. package/dist/plan/own-hostnames.d.ts +2 -0
  154. package/dist/plan/own-hostnames.js +1 -0
  155. package/dist/plan/own-jobs-route.d.ts +47 -0
  156. package/dist/plan/own-jobs-route.js +91 -0
  157. package/dist/plan/own-services.d.ts +4 -0
  158. package/dist/plan/own-services.js +4 -0
  159. package/dist/plan/plan-apply.d.ts +39 -0
  160. package/dist/plan/plan-apply.js +163 -9
  161. package/dist/plan/platform-diff.d.ts +2 -2
  162. package/dist/plan/platform-diff.js +7 -2
  163. package/dist/plan/platform-org.d.ts +30 -0
  164. package/dist/plan/platform-org.js +12 -1
  165. package/dist/plan/platform-providers.d.ts +2 -0
  166. package/dist/plan/platform-providers.js +7 -2
  167. package/dist/plan/post-logout-uris.js +7 -1
  168. package/dist/plan/private-pages.d.ts +26 -0
  169. package/dist/plan/private-pages.js +39 -0
  170. package/dist/plan/proxy-labels.d.ts +12 -1
  171. package/dist/plan/proxy-labels.js +32 -2
  172. package/dist/plan/release-host.d.ts +6 -2
  173. package/dist/plan/release-host.js +36 -8
  174. package/dist/plan/stage-env.d.ts +19 -1
  175. package/dist/plan/stage-env.js +25 -1
  176. package/dist/plan/stage-guards.d.ts +20 -0
  177. package/dist/plan/stage-guards.js +24 -0
  178. package/dist/plan/stage-isolation.d.ts +24 -0
  179. package/dist/plan/stage-isolation.js +29 -1
  180. package/dist/plan/worker-app-database.d.ts +47 -40
  181. package/dist/plan/worker-app-database.js +150 -152
  182. package/dist/plan/worker-app.d.ts +6 -65
  183. package/dist/plan/worker-app.js +29 -105
  184. package/dist/plan/worker.d.ts +2 -2
  185. package/dist/plan/worker.js +2 -1
  186. package/dist/plan/workspace-client.d.ts +25 -0
  187. package/dist/plan/workspace-client.js +48 -0
  188. package/dist/plan/workspace-sign-in.d.ts +55 -0
  189. package/dist/plan/workspace-sign-in.js +273 -0
  190. package/dist/plan/workspace-worker.d.ts +4 -0
  191. package/dist/plan/workspace-worker.js +13 -0
  192. package/dist/plan/workspace.d.ts +101 -0
  193. package/dist/plan/workspace.js +305 -0
  194. package/dist/release/decide.d.ts +30 -1
  195. package/dist/release/decide.js +73 -11
  196. package/dist/release/health.d.ts +8 -3
  197. package/dist/release/health.js +58 -5
  198. package/dist/release/run.d.ts +2 -5
  199. package/dist/release/run.js +4 -0
  200. package/dist/release/state.d.ts +3 -1
  201. package/dist/release/state.js +6 -0
  202. package/dist/release/types.d.ts +20 -0
  203. package/dist/requested-changes.d.ts +11 -2
  204. package/dist/requested-changes.js +11 -0
  205. package/dist/service-labels.d.ts +4 -0
  206. package/dist/service-labels.js +11 -0
  207. package/dist/slug.d.ts +2 -0
  208. package/dist/slug.js +10 -6
  209. package/dist/store/postgres.js +12 -2
  210. package/dist/store/schema.d.ts +37 -1
  211. package/dist/store/schema.js +4 -0
  212. package/dist/worker-app-config.d.ts +6 -10
  213. package/dist/worker-app-config.js +22 -17
  214. package/dist/worker-app-migrations.d.ts +105 -0
  215. package/dist/worker-app-migrations.js +166 -0
  216. package/dist/worker-app-upload.d.ts +103 -0
  217. package/dist/worker-app-upload.js +128 -0
  218. package/package.json +18 -1
@@ -0,0 +1,305 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { FULL_COMMIT_SHA, isLiveEntry, normalizeTesters, } from '../org.js';
3
+ import { PRIMARY_SERVER_SCOPE_KEY, bgKey, splitServers, stageAppName, stagePlanViolations, } from './blue-green.js';
4
+ import { withTimeout } from './call-timeout.js';
5
+ import { workspaceHost, workspaceInboxHost } from './hostnames.js';
6
+ import { appNameFor, appStepKey } from './platform-diff.js';
7
+ import { stageGuardsReady } from './stage-guards.js';
8
+ import { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_HTTP_PORT, STAGE_SINK_VERSION, stageSinkAppName, } from './stage-sink.js';
9
+ import { registerWorkspaceWorker } from './workspace-worker.js';
10
+ /**
11
+ * Private workspaces (`HostingEntry.workspace`). A workspace is where a company's team and invited testers build
12
+ * and test an app behind sign-in; `live` is a separate step that puts the app on its public address.
13
+ *
14
+ * A server entry's workspace is the blue/green stage (`blue-green-apply.ts`'s `ensureStage`): the stage app, its
15
+ * own empty database, the stage mail sink and the stage guards. It is served at the workspace host and has no
16
+ * colors, so it needs no `blueGreen`. A Worker app's workspace is a second Worker app `<slug>-<key>-work`.
17
+ * The gate over the workspace host is one Access gate per workspace, made by `planGates`.
18
+ */
19
+ const FULL_SHA = FULL_COMMIT_SHA;
20
+ /** One Coolify read of the workspace's deployments. */
21
+ const COMMIT_READ_TIMEOUT_MS = 15_000;
22
+ /** A Cloudflare Worker script name is at most 63 characters; so is a DNS label. */
23
+ const MAX_NAME = 63;
24
+ /** The scope keys of a workspace, beside the stage's `bg.<key>.…` ones. `.` separated, no `:`. */
25
+ export const wsKey = {
26
+ /** The hash of the workspace's shape last applied. Also the sign that its route may be written. */
27
+ applied: (key) => `ws.${key}.applied`,
28
+ /** The 40 character commit the workspace runs; the live app is made from it. */
29
+ commit: (key) => `ws.${key}.commit`,
30
+ /** The commit the live app was last made from or moved to by a promote (`HostingEntry.liveCommit`). */
31
+ liveCommit: (key) => `ws.${key}.livecommit`,
32
+ /** The non-secret sign-in env the workspace was last applied with (JSON); never a key. */
33
+ signIn: (key) => `ws.${key}.signin`,
34
+ };
35
+ /** The key of a workspace's Access gate in the company. */
36
+ export const workspaceGateKey = (entryKey) => `ws-${entryKey}`;
37
+ /** The key of the gate over the test inbox. Entry keys cannot start with a hyphen, so it never clashes with `ws-<key>`. */
38
+ export const INBOX_GATE_KEY = 'ws--inbox';
39
+ /** True when the org has a server workspace, and so a stage mail sink to show. */
40
+ export const hasInbox = (org) => workspaceEntries(org).some(isServerWorkspace);
41
+ /** The people-list hash a gate was last made or changed with. */
42
+ export const gatePeopleScopeKey = (gateKey) => `gate_people_${gateKey}`;
43
+ /** The entries of an org that declare a workspace. */
44
+ export function workspaceEntries(org) {
45
+ return org.hosting.enabled ? org.hosting.entries.filter((e) => e.workspace !== undefined) : [];
46
+ }
47
+ /** True for a workspace that runs as a server (Coolify), false for a Worker app's. */
48
+ export const isServerWorkspace = (entry) => entry.workspace !== undefined && entry.workerApp === undefined;
49
+ /**
50
+ * The company's people who may open company apps: the owners, by sign-in address and work address. This is the
51
+ * list the stage gate used (owners only); the owners are the only company people the gate inputs hold.
52
+ */
53
+ export function companyPeople(org) {
54
+ return normalizeTesters(org.owners.flatMap((owner) => owner.mailboxAddress === undefined ? [owner.email] : [owner.email, owner.mailboxAddress])).sort();
55
+ }
56
+ /** Who passes a workspace's gate: the company's people and the entry's testers, lowercased, distinct, sorted. */
57
+ export function workspacePeople(org, entry) {
58
+ return normalizeTesters([...companyPeople(org), ...(entry.workspace?.testers ?? [])]).sort();
59
+ }
60
+ /** The hash recorded when a gate's people were set. A change of testers changes it. */
61
+ export const peopleHash = (emails) => createHash('sha256').update(JSON.stringify(emails)).digest('hex').slice(0, 16);
62
+ /** The org without the entries that are not live: nothing that makes an app or its address sees them. */
63
+ export function withoutDarkEntries(org) {
64
+ if (org.hosting.entries.every(isLiveEntry))
65
+ return org;
66
+ return { ...org, hosting: { ...org.hosting, entries: org.hosting.entries.filter(isLiveEntry) } };
67
+ }
68
+ /**
69
+ * The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
70
+ * executor deploy it like any other. The copy has no database and no sign-in env (the workspace gate is its
71
+ * sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
72
+ * workspaces are read from when `org` has had dark entries removed.
73
+ */
74
+ export function withWorkspaceWorkers(org, declared = org) {
75
+ if (!org.hosting.enabled)
76
+ return org;
77
+ const copies = [];
78
+ for (const entry of declared.hosting.entries) {
79
+ if (!entry.workspace || !entry.workerApp)
80
+ continue;
81
+ const { workspace: _w, live: _l, hostedName: _h, uses: _u, workerApp, ...rest } = entry;
82
+ const { database: _d, authEnv: _a, ...app } = workerApp;
83
+ const copy = {
84
+ ...rest,
85
+ key: `${entry.key}-work`,
86
+ main: false,
87
+ hostnames: [],
88
+ gates: [],
89
+ env: {},
90
+ database: false,
91
+ workerApp: app,
92
+ };
93
+ registerWorkspaceWorker(copy, entry.key);
94
+ copies.push(copy);
95
+ }
96
+ if (copies.length === 0)
97
+ return org;
98
+ return { ...org, hosting: { ...org.hosting, entries: [...org.hosting.entries, ...copies] } };
99
+ }
100
+ /** A server workspace as the blue/green stage code sees its subject: no colors, no hostnames, no reporting. */
101
+ export function workspaceSubject(org, entry) {
102
+ const base = appNameFor(org, entry);
103
+ const isStatic = entry.buildPack === 'static';
104
+ return {
105
+ id: entry.key,
106
+ base,
107
+ primaryBlueName: base,
108
+ port: isStatic ? 80 : (entry.port ?? 80),
109
+ repo: entry.repo,
110
+ folder: entry.folder,
111
+ branch: entry.branch,
112
+ buildPack: entry.buildPack,
113
+ healthPath: entry.healthPath ?? (isStatic ? '/' : '/api/health'),
114
+ database: entry.database,
115
+ hostnames: [],
116
+ config: {},
117
+ blueAppKey: `coolify:app:${appStepKey(org, entry.key)}`,
118
+ reporting: false,
119
+ };
120
+ }
121
+ /** The origin the tunnel sends a server workspace's host to: the stage app on the company network. */
122
+ export const workspaceOrigin = (subject) => `http://${stageAppName(subject.base)}:${subject.port}`;
123
+ /**
124
+ * The tunnel routes of the server workspaces: the workspace host to the stage app. A route is written only once
125
+ * the workspace was applied and its stage guards hold (isolated, probed, gated, healthy), never before.
126
+ */
127
+ export function workspaceRoutes(org, recorded = {}) {
128
+ const inbox = hasInbox(org) &&
129
+ recorded[STAGE_SINK_APPLIED_SCOPE_KEY] &&
130
+ recorded[`cloudflare:gate:${org.id}:${INBOX_GATE_KEY}`]
131
+ ? [
132
+ {
133
+ hostname: workspaceInboxHost(org.slug),
134
+ service: `http://${stageSinkAppName(org)}:${STAGE_SINK_HTTP_PORT}`,
135
+ },
136
+ ]
137
+ : [];
138
+ return [
139
+ ...workspaceEntries(org)
140
+ .filter((entry) => isServerWorkspace(entry) &&
141
+ recorded[wsKey.applied(entry.key)] &&
142
+ stageGuardsReady(recorded, entry.key).ready)
143
+ .map((entry) => ({
144
+ hostname: workspaceHost(org.slug, entry.key),
145
+ service: workspaceOrigin(workspaceSubject(org, entry)),
146
+ })),
147
+ ...inbox,
148
+ ];
149
+ }
150
+ /** Whether `name` is the workspace host of one of the org's server workspaces. */
151
+ export function ownsWorkspaceName(org, name) {
152
+ const lower = name.toLowerCase();
153
+ if (hasInbox(org) && lower === workspaceInboxHost(org.slug))
154
+ return true;
155
+ return workspaceEntries(org).some((entry) => isServerWorkspace(entry) && workspaceHost(org.slug, entry.key) === lower);
156
+ }
157
+ /** A short hash of what decides the shape of a server workspace, so a change replans it. */
158
+ export function workspaceStructureHash(org, entry) {
159
+ const subject = workspaceSubject(org, entry);
160
+ const auth = entry.uses?.auth;
161
+ return createHash('sha256')
162
+ .update(JSON.stringify({
163
+ base: subject.base,
164
+ port: subject.port,
165
+ repo: subject.repo,
166
+ folder: subject.folder,
167
+ branch: subject.branch,
168
+ buildPack: subject.buildPack,
169
+ healthPath: subject.healthPath,
170
+ database: subject.database,
171
+ host: workspaceHost(org.slug, entry.key),
172
+ stageSink: STAGE_SINK_VERSION,
173
+ ...(auth
174
+ ? {
175
+ signIn: {
176
+ users: auth.users,
177
+ registration: auth.registration,
178
+ signIn: auth.signIn ?? null,
179
+ workspaces: auth.workspaces ?? null,
180
+ },
181
+ }
182
+ : {}),
183
+ }))
184
+ .digest('hex')
185
+ .slice(0, 16);
186
+ }
187
+ /**
188
+ * One `coolify:workspace` step per server workspace whose shape is not applied yet or whose stage needs another
189
+ * try; the Worker-app workspaces are planned by `planWorkerApps` through {@link withWorkspaceWorkers}. Pure.
190
+ * Blocked, not planned, while the host cannot list servers, the primary is unusable, the stage would not be
191
+ * isolated from the company's other apps, or the gate is not there: a workspace nobody can sign in to is not made.
192
+ */
193
+ export function planWorkspaces(input) {
194
+ const { org, recordedIds } = input;
195
+ const steps = [];
196
+ const unmanaged = [];
197
+ for (const entry of workspaceEntries(org)) {
198
+ if (!isServerWorkspace(entry))
199
+ continue;
200
+ const id = entry.key;
201
+ const at = `${org.slug}'s ${id} workspace`;
202
+ const subject = workspaceSubject(org, entry);
203
+ const blocked = (why, part = 'blocked') => unmanaged.push({
204
+ description: `${at} is blocked, not planned: ${why}`,
205
+ key: `workspace:${id}:${part}`,
206
+ });
207
+ if (entry.buildPack === 'dockercompose')
208
+ continue;
209
+ if (stageAppName(subject.base).length > MAX_NAME ||
210
+ (workspaceHost(org.slug, id).split('.')[0] ?? '').length > MAX_NAME) {
211
+ blocked('its app name or address is too long; shorten the slug or the entry key', 'name');
212
+ continue;
213
+ }
214
+ const violations = stagePlanViolations(subject, org.hosting.entries.filter((e) => e.key !== id).map((e) => appNameFor(org, e)));
215
+ if (violations.length > 0) {
216
+ blocked(`its stage would not be isolated from the company's other apps (${violations.join(', ')})`, 'stageisolation');
217
+ continue;
218
+ }
219
+ if (!input.servers) {
220
+ blocked('the host cannot list Coolify servers');
221
+ continue;
222
+ }
223
+ const { up } = splitServers(input.servers);
224
+ const primary = recordedIds[PRIMARY_SERVER_SCOPE_KEY] ?? up[0];
225
+ if (!primary || !up.includes(primary)) {
226
+ blocked(`the primary Coolify server ${primary ?? '(none)'} is not usable and reachable`);
227
+ continue;
228
+ }
229
+ if (!input.gateReady(workspaceGateKey(id))) {
230
+ blocked('its Access gate is not recorded or planned yet', 'gate');
231
+ continue;
232
+ }
233
+ const signInWanted = entry.uses?.auth && input.signInWanted ? input.signInWanted(entry) : undefined;
234
+ if (signInWanted === null) {
235
+ blocked('its sign-in organisation, client or issuer is not recorded or planned yet', 'signin');
236
+ continue;
237
+ }
238
+ const stageState = recordedIds[bgKey.stageState(id)];
239
+ if (stageState === 'unhealthy') {
240
+ unmanaged.push({
241
+ description: `${at}: the stage app is not healthy yet`,
242
+ key: `workspace:${id}:stage`,
243
+ });
244
+ }
245
+ else if (stageState === 'blocked:sink' || input.stageSinkDown === true) {
246
+ unmanaged.push({
247
+ description: `${at}: the stage mail sink is not running; the workspace gets no mail env`,
248
+ key: `workspace:${id}:stage`,
249
+ });
250
+ }
251
+ else if (stageState?.startsWith('blocked:')) {
252
+ unmanaged.push({
253
+ description: `${at}: the stage is blocked and was not written to (${stageState.slice('blocked:'.length)})`,
254
+ key: `workspace:${id}:stage`,
255
+ });
256
+ }
257
+ const retry = stageState === 'unhealthy' ||
258
+ stageState?.startsWith('blocked:') === true ||
259
+ input.stageSinkDown === true;
260
+ const signInStale = signInWanted !== undefined && recordedIds[wsKey.signIn(id)] !== signInWanted;
261
+ if (recordedIds[wsKey.applied(id)] === workspaceStructureHash(org, entry) &&
262
+ !retry &&
263
+ !signInStale) {
264
+ continue;
265
+ }
266
+ steps.push({
267
+ action: {
268
+ system: 'coolify',
269
+ kind: 'workspace',
270
+ key: appStepKey(org, id),
271
+ desired: {
272
+ entry: id,
273
+ server: primary,
274
+ primary,
275
+ ...(signInWanted !== undefined && input.authIssuer
276
+ ? { authIssuer: input.authIssuer }
277
+ : {}),
278
+ },
279
+ },
280
+ step: {
281
+ description: `run ${at} at ${workspaceHost(org.slug, id)}: a stage copy of the app with its own empty database, behind sign-in${recordedIds[wsKey.applied(id)] ? '' : ' (first time)'}`,
282
+ destroys: false,
283
+ migrates: false,
284
+ },
285
+ });
286
+ }
287
+ return { steps, unmanaged };
288
+ }
289
+ /**
290
+ * The commit of the newest finished deployment of the workspace's stage app, or `null` when the port cannot
291
+ * list deployments, the read fails or there is none. The live app of a workspace entry is made from it.
292
+ */
293
+ export async function readWorkspaceCommit(coolify, stageAppId) {
294
+ const list = coolify.listApplicationDeployments?.bind(coolify);
295
+ if (!list)
296
+ return null;
297
+ try {
298
+ const rows = await withTimeout('workspace commit: list deployments', COMMIT_READ_TIMEOUT_MS, () => list(stageAppId));
299
+ const finished = rows.find((row) => row.status === 'finished');
300
+ return finished?.commit && FULL_SHA.test(finished.commit) ? finished.commit : null;
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ }
@@ -1,4 +1,4 @@
1
- import { type Color, type ReleaseAction, type ReleaseSignal, type WindowState } from './types.js';
1
+ import { type Color, type ReleaseAction, type ReleaseConfig, type ReleaseSignal, type WindowState } from './types.js';
2
2
  /** ADR 0001 rule 5: the default steps and the least time on each. */
3
3
  export declare const DEFAULT_STEPS: readonly number[];
4
4
  export declare const DEFAULT_STEP_HOLD_MINUTES = 60;
@@ -15,8 +15,24 @@ export declare const ERROR_RATE_MARGIN = 0.02;
15
15
  /** Rules 12 and 13: the window at 100% closes after this long, and at most this long. */
16
16
  export declare const WINDOW_MIN_HOURS = 24;
17
17
  export declare const WINDOW_CAP_DAYS = 7;
18
+ /** A `signal` step that waited this long since it began without a passing check accepts `approve_step` and reports a blocked line. */
19
+ export declare const STEP_SIGNAL_CAP_HOURS = 24;
18
20
  /** The new color must be running and answer on its own address within this time of the deploy. */
19
21
  export declare const DEPLOY_TIMEOUT_MINUTES = 30;
22
+ /** A `deploy_stage` ref: a branch name or a full 40-hex commit sha. */
23
+ export declare const STAGE_REF_PATTERN: RegExp;
24
+ export declare const DEFAULT_ADVANCE: ReleaseConfig['advance'];
25
+ /** The release kind fields of an entry's `blueGreen`. */
26
+ export interface ReleaseEntryConfig {
27
+ steps?: readonly number[] | undefined;
28
+ stepHoldMinutes?: number | undefined;
29
+ advance?: ReleaseConfig['advance'] | undefined;
30
+ minRequests?: number | undefined;
31
+ errorMargin?: number | undefined;
32
+ staffFirst?: boolean | undefined;
33
+ }
34
+ /** The entry's release kind with every default filled in. */
35
+ export declare function resolveReleaseConfig(entry: ReleaseEntryConfig | undefined): ReleaseConfig;
20
36
  export interface ActiveRelease {
21
37
  from: Color;
22
38
  to: Color;
@@ -64,6 +80,11 @@ export type ReleaseEffect = {
64
80
  } | {
65
81
  kind: 'stop';
66
82
  color: Color;
83
+ }
84
+ /** Deploy stage at this ref. Decide only passes it on; the host executes it. */
85
+ | {
86
+ kind: 'deploy_stage';
87
+ ref: string;
67
88
  };
68
89
  export interface Decision {
69
90
  recorded: Recorded;
@@ -90,6 +111,14 @@ export declare function decide(input: {
90
111
  actions: readonly ReleaseAction[];
91
112
  steps?: readonly number[] | undefined;
92
113
  stepHoldMinutes?: number | undefined;
114
+ /** How a step is left; default `signal`. */
115
+ advance?: ReleaseConfig['advance'] | undefined;
116
+ /** Requests the new color needs before its error rate counts; default RELATIVE_MIN_REQUESTS. */
117
+ minRequests?: number | undefined;
118
+ /** Error rate allowed above the baseline; default ERROR_RATE_MARGIN. */
119
+ errorMargin?: number | undefined;
120
+ /** A promote waits at weight 0 in `staff` until an `open` action. */
121
+ staffFirst?: boolean | undefined;
93
122
  now: Date;
94
123
  observation: Observation;
95
124
  /**
@@ -15,11 +15,25 @@ export const ERROR_RATE_MARGIN = 0.02;
15
15
  /** Rules 12 and 13: the window at 100% closes after this long, and at most this long. */
16
16
  export const WINDOW_MIN_HOURS = 24;
17
17
  export const WINDOW_CAP_DAYS = 7;
18
+ /** A `signal` step that waited this long since it began without a passing check accepts `approve_step` and reports a blocked line. */
19
+ export const STEP_SIGNAL_CAP_HOURS = 24;
18
20
  /** The new color must be running and answer on its own address within this time of the deploy. */
19
21
  export const DEPLOY_TIMEOUT_MINUTES = 30;
20
22
  const MINUTE = 60_000;
21
23
  const HOUR = 60 * MINUTE;
22
24
  const DAY = 24 * HOUR;
25
+ /** A `deploy_stage` ref: a branch name or a full 40-hex commit sha. */
26
+ export const STAGE_REF_PATTERN = /^(?:[A-Za-z0-9._/-]{1,200}|[0-9a-f]{40})$/;
27
+ export const DEFAULT_ADVANCE = 'signal';
28
+ /** The entry's release kind with every default filled in. */
29
+ export function resolveReleaseConfig(entry) {
30
+ return {
31
+ advance: entry?.advance ?? DEFAULT_ADVANCE,
32
+ steps: normalizeSteps(entry?.steps),
33
+ stepHoldMinutes: entry?.stepHoldMinutes ?? DEFAULT_STEP_HOLD_MINUTES,
34
+ staffFirst: entry?.staffFirst ?? false,
35
+ };
36
+ }
23
37
  export function normalizeSteps(steps) {
24
38
  const wanted = steps && steps.length > 0 ? steps : DEFAULT_STEPS;
25
39
  const valid = [...new Set(wanted.filter((n) => Number.isInteger(n) && n >= 1 && n <= 100))].sort((a, b) => a - b);
@@ -61,12 +75,41 @@ export function healthRange(r, now) {
61
75
  }
62
76
  return null;
63
77
  }
64
- function applyActions(r, actions, now, out) {
78
+ function applyActions(r, actions, now, out, kind) {
65
79
  let cur = r;
66
80
  let promoted = false;
67
81
  let ended = false;
82
+ let approved = false;
83
+ let opened = false;
68
84
  for (const a of actions) {
69
- if (a.kind === 'clear_held') {
85
+ if (a.kind === 'deploy_stage') {
86
+ if (typeof a.ref === 'string' && STAGE_REF_PATTERN.test(a.ref))
87
+ out.effects.push({ kind: 'deploy_stage', ref: a.ref });
88
+ else
89
+ out.refused.push('deploy_stage refused: the ref is not a branch name or a commit sha');
90
+ }
91
+ else if (a.kind === 'approve_step') {
92
+ const w = cur.window;
93
+ const capped = kind.advance === 'signal' &&
94
+ 'since' in w &&
95
+ now.getTime() - Date.parse(w.since) >= STEP_SIGNAL_CAP_HOURS * HOUR;
96
+ const waiting = (kind.advance === 'approval' || capped) &&
97
+ cur.release !== null &&
98
+ ((w.kind === 'step' && w.percent > 0) || (w.kind === 'inconclusive' && w.percent > 0));
99
+ if (waiting)
100
+ approved = true;
101
+ else
102
+ out.refused.push('approve_step refused: no step is waiting for approval');
103
+ }
104
+ else if (a.kind === 'open') {
105
+ if (cur.window.kind === 'staff' && cur.release) {
106
+ cur = stepTo(cur, cur.release, kind.steps[0], now);
107
+ opened = true;
108
+ }
109
+ else
110
+ out.refused.push('open refused: no release is waiting for staff');
111
+ }
112
+ else if (a.kind === 'clear_held') {
70
113
  cur = { ...cur, held: cur.held.filter((d) => d !== a.digest) };
71
114
  }
72
115
  else if (a.kind === 'promote') {
@@ -114,7 +157,7 @@ function applyActions(r, actions, now, out) {
114
157
  }
115
158
  }
116
159
  }
117
- return { recorded: cur, promoted, ended };
160
+ return { recorded: cur, promoted, ended, approved, opened };
118
161
  }
119
162
  /**
120
163
  * The whole release decision for one run: from the recorded state, the
@@ -126,12 +169,15 @@ export function decide(input) {
126
169
  const steps = normalizeSteps(input.steps);
127
170
  const holdMs = (input.stepHoldMinutes ?? DEFAULT_STEP_HOLD_MINUTES) * MINUTE;
128
171
  const out = { effects: [], refused: [] };
129
- const applied = applyActions(input.recorded, input.actions, now, out);
172
+ const advance = input.advance ?? DEFAULT_ADVANCE;
173
+ const minRequests = input.minRequests ?? RELATIVE_MIN_REQUESTS;
174
+ const errorMargin = input.errorMargin ?? ERROR_RATE_MARGIN;
175
+ const applied = applyActions(input.recorded, input.actions, now, out, { advance, steps });
130
176
  let r = applied.recorded;
131
177
  let signal = null;
132
178
  const blocked = [];
133
179
  const rel = r.release;
134
- if (!applied.promoted && !applied.ended && rel) {
180
+ if (!applied.promoted && !applied.ended && !applied.opened && rel) {
135
181
  const since = Date.parse('since' in r.window ? r.window.since : '');
136
182
  const elapsed = now.getTime() - since;
137
183
  const hard = observation.status === 'crash_loop'
@@ -143,12 +189,19 @@ export function decide(input) {
143
189
  if (hard)
144
190
  r = rollback(r, now, hard);
145
191
  else if (observation.status === 'running' && observation.address === 'ok') {
146
- const first = steps[0];
147
- r = stepTo(r, rel, first, now);
192
+ if (input.staffFirst)
193
+ r = { ...r, window: { kind: 'staff', since: now.toISOString() } };
194
+ else
195
+ r = stepTo(r, rel, steps[0], now);
148
196
  }
149
197
  else if (elapsed >= DEPLOY_TIMEOUT_MINUTES * MINUTE)
150
198
  r = rollback(r, now, 'deploy timeout');
151
199
  }
200
+ else if (r.window.kind === 'staff') {
201
+ // Weight 0: only a hard fail (crash loop, failed address) rolls back. `open` leaves it.
202
+ if (hard)
203
+ r = rollback(r, now, hard);
204
+ }
152
205
  else {
153
206
  const health = input.hardOnly ? null : observation.health;
154
207
  const newC = health?.colors[rel.to] ?? { requests: 0, errors: 0 };
@@ -164,10 +217,10 @@ export function decide(input) {
164
217
  verdict = 'hard_fail';
165
218
  else if (input.hardOnly)
166
219
  verdict = 'pass';
167
- else if (!health || baseline === null || newC.requests < RELATIVE_MIN_REQUESTS)
220
+ else if (!health || baseline === null || newC.requests < minRequests)
168
221
  verdict = 'inconclusive';
169
222
  else
170
- verdict = rate(newC) > baseline + ERROR_RATE_MARGIN ? 'fail' : 'pass';
223
+ verdict = rate(newC) > baseline + errorMargin ? 'fail' : 'pass';
171
224
  signal = { requests: newC.requests, errors: newC.errors, baselineRate: baseline, verdict };
172
225
  if (verdict === 'hard_fail') {
173
226
  r = rollback(r, now, hard ?? 'hard fail: error rate');
@@ -188,7 +241,14 @@ export function decide(input) {
188
241
  if (!atWindow) {
189
242
  const pct = r.window.percent;
190
243
  const holdDone = elapsed >= holdMs;
191
- if (holdDone && verdict !== 'fail') {
244
+ const stepUp = advance === 'time'
245
+ ? holdDone && verdict !== 'fail'
246
+ : advance === 'signal'
247
+ ? (holdDone && verdict === 'pass') || (applied.approved && verdict !== 'fail')
248
+ : applied.approved && verdict !== 'fail';
249
+ if (applied.approved && advance !== 'time' && verdict === 'fail')
250
+ out.refused.push('approve_step refused: the error rate is failing');
251
+ if (stepUp) {
192
252
  const next = steps.find((s) => s > pct);
193
253
  r = stepTo(r, rel, next, now);
194
254
  }
@@ -198,6 +258,8 @@ export function decide(input) {
198
258
  ...r,
199
259
  window: { kind, percent: pct, since: r.window.since },
200
260
  };
261
+ if (advance === 'signal' && elapsed >= STEP_SIGNAL_CAP_HOURS * HOUR)
262
+ blocked.push('step waiting for traffic: approve the step or roll back in Archon');
201
263
  }
202
264
  }
203
265
  else {
@@ -208,7 +270,7 @@ export function decide(input) {
208
270
  baseline !== null &&
209
271
  elapsed >= WINDOW_MIN_HOURS * HOUR &&
210
272
  newC.requests >= required &&
211
- rate(newC) <= baseline + ERROR_RATE_MARGIN;
273
+ rate(newC) <= baseline + errorMargin;
212
274
  if (closable) {
213
275
  out.effects.push({ kind: 'stop', color: rel.from });
214
276
  r = { ...r, window: { kind: 'passed', at: now.toISOString() }, release: null };
@@ -4,6 +4,8 @@ import type { HealthRead } from './decide.js';
4
4
  export declare const HEALTH_READ_TIMEOUT_MS = 10000;
5
5
  /** The color's own address check has this long to answer. */
6
6
  export declare const ADDRESS_CHECK_TIMEOUT_MS = 10000;
7
+ /** A health body larger than this is refused unread (a runaway or hostile response must not fill memory). */
8
+ export declare const HEALTH_BODY_MAX_BYTES: number;
7
9
  /** C1's 200 body. */
8
10
  export declare const healthBody: z.ZodObject<{
9
11
  colors: z.ZodObject<{
@@ -40,14 +42,17 @@ export declare function readReleaseHealth(input: {
40
42
  fetchImpl?: typeof fetch;
41
43
  }): Promise<HealthRead | null>;
42
44
  /**
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
+ * An HTTP check of the color's health path on its own address. A redirect to another origin is a failure: it is
46
+ * how a login wall (Cloudflare Access) answers a machine. One same-origin redirect (a trailing-slash 308) is
47
+ * followed and the answer judged; a second redirect fails. The body is read under {@link HEALTH_BODY_MAX_BYTES}.
48
+ * With `expectJson` it must parse as JSON. Without, it must not be Access's login page (a 200 that
49
+ * carries `cloudflareaccess.com` or `/cdn-cgi/access/`), so a login wall is never read as a healthy static app.
45
50
  */
46
51
  export declare function checkColorAddress(input: {
47
52
  address: string;
48
53
  healthPath: string | null;
49
54
  headers?: Record<string, string>;
50
- /** True for an app whose health path answers JSON; false for a static app's `/`. */
55
+ /** True for an app with a server, whose health path answers JSON; false for a static app's `/`. */
51
56
  expectJson?: boolean;
52
57
  fetchImpl?: typeof fetch;
53
58
  }): Promise<'ok' | 'failed'>;
@@ -3,6 +3,36 @@ import { z } from 'zod';
3
3
  export const HEALTH_READ_TIMEOUT_MS = 10_000;
4
4
  /** The color's own address check has this long to answer. */
5
5
  export const ADDRESS_CHECK_TIMEOUT_MS = 10_000;
6
+ /** A health body larger than this is refused unread (a runaway or hostile response must not fill memory). */
7
+ export const HEALTH_BODY_MAX_BYTES = 64 * 1024;
8
+ /**
9
+ * The response body as text, or null when it is larger than {@link HEALTH_BODY_MAX_BYTES}. Reads in chunks and
10
+ * stops at the cap, so an endless body is never held.
11
+ */
12
+ async function readCappedText(res) {
13
+ const declared = Number(res.headers.get('content-length'));
14
+ if (Number.isFinite(declared) && declared > HEALTH_BODY_MAX_BYTES)
15
+ return null;
16
+ if (!res.body)
17
+ return '';
18
+ const reader = res.body.getReader();
19
+ const chunks = [];
20
+ let total = 0;
21
+ for (;;) {
22
+ const { done, value } = await reader.read();
23
+ if (done)
24
+ break;
25
+ total += value.byteLength;
26
+ if (total > HEALTH_BODY_MAX_BYTES) {
27
+ await reader.cancel().catch(() => undefined);
28
+ return null;
29
+ }
30
+ chunks.push(value);
31
+ }
32
+ return new TextDecoder().decode(Buffer.concat(chunks));
33
+ }
34
+ /** What Cloudflare Access's login page carries: its hosted domain and its path prefix. */
35
+ const ACCESS_PAGE_MARKERS = ['cloudflareaccess.com', '/cdn-cgi/access/'];
6
36
  const counts = z.object({
7
37
  requests: z.number().int().nonnegative(),
8
38
  errors: z.number().int().nonnegative(),
@@ -35,7 +65,10 @@ export async function readReleaseHealth(input) {
35
65
  });
36
66
  if (!res.ok)
37
67
  return null;
38
- const parsed = healthBody.safeParse(await res.json());
68
+ const text = await readCappedText(res);
69
+ if (text === null)
70
+ return null;
71
+ const parsed = healthBody.safeParse(JSON.parse(text));
39
72
  if (!parsed.success)
40
73
  return null;
41
74
  return { colors: parsed.data.colors, daily: parsed.data.daily };
@@ -46,20 +79,40 @@ export async function readReleaseHealth(input) {
46
79
  }
47
80
  }
48
81
  /**
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.
82
+ * An HTTP check of the color's health path on its own address. A redirect to another origin is a failure: it is
83
+ * how a login wall (Cloudflare Access) answers a machine. One same-origin redirect (a trailing-slash 308) is
84
+ * followed and the answer judged; a second redirect fails. The body is read under {@link HEALTH_BODY_MAX_BYTES}.
85
+ * With `expectJson` it must parse as JSON. Without, it must not be Access's login page (a 200 that
86
+ * carries `cloudflareaccess.com` or `/cdn-cgi/access/`), so a login wall is never read as a healthy static app.
51
87
  */
52
88
  export async function checkColorAddress(input) {
53
89
  try {
54
- const res = await (input.fetchImpl ?? fetch)(new URL(input.healthPath ?? '/', input.address), {
90
+ const doFetch = input.fetchImpl ?? fetch;
91
+ const get = (url) => doFetch(url, {
55
92
  ...(input.headers ? { headers: input.headers } : {}),
56
93
  redirect: 'manual',
57
94
  signal: AbortSignal.timeout(ADDRESS_CHECK_TIMEOUT_MS),
58
95
  });
96
+ const first = new URL(input.healthPath ?? '/', input.address);
97
+ let res = await get(first);
98
+ if (res.status >= 300 && res.status < 400) {
99
+ const location = res.headers.get('location');
100
+ if (!location)
101
+ return 'failed';
102
+ const next = new URL(location, first);
103
+ if (next.origin !== first.origin)
104
+ return 'failed';
105
+ res = await get(next);
106
+ }
59
107
  if (!res.ok)
60
108
  return 'failed';
109
+ const text = await readCappedText(res);
110
+ if (text === null)
111
+ return 'failed';
61
112
  if (input.expectJson)
62
- await res.json();
113
+ JSON.parse(text);
114
+ else if (ACCESS_PAGE_MARKERS.some((marker) => text.includes(marker)))
115
+ return 'failed';
63
116
  return 'ok';
64
117
  }
65
118
  catch {
@@ -1,4 +1,4 @@
1
- import { type Decision, type Observation, type ReleaseEffect } from './decide.js';
1
+ import { type Decision, type Observation, type ReleaseEffect, type ReleaseEntryConfig } from './decide.js';
2
2
  import { type ReleaseStore } from './state.js';
3
3
  import type { Color } from './types.js';
4
4
  /** What the runner needs from the outside. Each call is bounded by its own timeout. */
@@ -31,10 +31,7 @@ export declare function runRelease(input: {
31
31
  store: ReleaseStore;
32
32
  orgId: string;
33
33
  entryKey: string;
34
- entry: {
35
- steps?: number[];
36
- stepHoldMinutes?: number;
37
- };
34
+ entry: ReleaseEntryConfig;
38
35
  now: Date;
39
36
  deps: ReleaseDeps;
40
37
  /** No reporting health read: only hard fails count (see `decide`). */