@wtfalch/foundry 0.60.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.
- package/dist/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +5 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/org.d.ts +163 -22
- package/dist/org.js +419 -47
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +41 -1
- package/dist/plan/blue-green.js +80 -2
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/desktop-callback.js +12 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +373 -42
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +191 -1
- package/dist/plan/executor.js +1505 -90
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +2 -2
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.js +12 -1
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +7 -2
- package/dist/plan/post-logout-uris.js +7 -1
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +11 -0
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +10 -6
- package/dist/store/postgres.js +12 -2
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +4 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +18 -1
|
@@ -899,6 +899,11 @@ export function resolveAppEnv(org, entry, recorded, available = { sharedSecret:
|
|
|
899
899
|
else if (typeof declared !== 'string')
|
|
900
900
|
unresolved.push([name, declared.from]);
|
|
901
901
|
}
|
|
902
|
+
// F13: the entry's customer-grants credential is held and written by the app step. The marker names it,
|
|
903
|
+
// never its value, so adding or removing the flag replans the step.
|
|
904
|
+
if (entry.uses?.customerGrants && available.sharedSecret) {
|
|
905
|
+
marker.CUSTOMER_GRANTS_CREDENTIAL = 'secret:customerGrants';
|
|
906
|
+
}
|
|
902
907
|
return { values, unresolved, secrets, unresolvedSecrets, heldSecrets, unresolvedHeld, marker };
|
|
903
908
|
}
|
|
904
909
|
/**
|
|
@@ -1720,8 +1725,8 @@ export const JOBS_SCHEDULER_KEY = 'jobs';
|
|
|
1720
1725
|
/**
|
|
1721
1726
|
* The scheduler's address on the Coolify network (`http://<custom_internal_name>:8080`),
|
|
1722
1727
|
* the value the org steps' jobs port and Archon's `JOBS_URL` are read from.
|
|
1723
|
-
* Not a secret.
|
|
1724
|
-
*
|
|
1728
|
+
* Not a secret. Only containers on that network reach it; an entry's app uses
|
|
1729
|
+
* the owner company's public origin `JOBS_PUBLIC_URL_SCOPE_KEY` (`own-jobs-route.ts`) instead.
|
|
1725
1730
|
*/
|
|
1726
1731
|
export const JOBS_INTERNAL_URL_SCOPE_KEY = 'jobs_internal_url';
|
|
1727
1732
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DeclaredOrg, type VersionPin } from '../org.js';
|
|
2
2
|
import type { DeclaredPlatform } from '../platform.js';
|
|
3
3
|
import { type AgoraHostValues } from './agora.js';
|
|
4
|
+
import type { RepoKeyProbe } from './entry-repo-key.js';
|
|
4
5
|
/**
|
|
5
6
|
* #443: the no-domain company mail apex -- `<slug>.oikos.host`, the same
|
|
6
7
|
* fixed suffix `agora.ts`'s `agoraHostnameFor`, `boule.ts`'s
|
|
@@ -80,6 +81,12 @@ export interface OrgPlatformReadiness {
|
|
|
80
81
|
* gets as `AI_URL`. `null` or absent until the platform has applied the AI host.
|
|
81
82
|
*/
|
|
82
83
|
aiUrl?: string | null;
|
|
84
|
+
/**
|
|
85
|
+
* The public origin of the jobs scheduler an entry with `uses.jobs` registers on, which it gets as
|
|
86
|
+
* `JOBS_SCHEDULER_URL`. A company on shared services reads the owner company's (`ownerJobsPublicUrl`); a
|
|
87
|
+
* company that runs its own services reads its own. `null` or absent until that route is recorded.
|
|
88
|
+
*/
|
|
89
|
+
jobsPublicUrl?: string | null;
|
|
83
90
|
/**
|
|
84
91
|
* The shared calls SFU (#170) an org's own calls Matrix stack depends on.
|
|
85
92
|
* Declared is enough here: `platform-diff.ts`'s `planSfu` plans the step
|
|
@@ -187,6 +194,24 @@ export interface OrgPlatformReadiness {
|
|
|
187
194
|
unreadable?: readonly string[];
|
|
188
195
|
invalid?: readonly string[];
|
|
189
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* The platform GitHub App's held keys (`PLATFORM_GITHUB_APP_REFS`), read only for a company that declares
|
|
199
|
+
* `hosting.github`. `store` is false when the host wired no held-keys reader; `missing` are the refs that hold
|
|
200
|
+
* no value now. Refs only, never a value. Absent reads as no store.
|
|
201
|
+
*/
|
|
202
|
+
githubAppKeys?: {
|
|
203
|
+
store: boolean;
|
|
204
|
+
missing: readonly string[];
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* F12: what GitHub said about the repo of each entry that declares `uses.repo` (or has a key to remove), by
|
|
208
|
+
* entry key. `store` is false when the host wired no GitHub deploy-key client. Lines only, never a credential.
|
|
209
|
+
* Absent for an org with neither.
|
|
210
|
+
*/
|
|
211
|
+
repoKeys?: {
|
|
212
|
+
store: boolean;
|
|
213
|
+
probes: Readonly<Record<string, RepoKeyProbe>>;
|
|
214
|
+
};
|
|
190
215
|
/**
|
|
191
216
|
* True when the host wired no held-keys reader, so no key could be checked.
|
|
192
217
|
* A missing key is then reported as "no reader given" instead of "not held".
|
|
@@ -277,6 +302,11 @@ export declare const OWN_FILES_RUNNER_SCOPE_KEY = "coolify_platform_files_runner
|
|
|
277
302
|
* `base`. Any other org gets `base` back unchanged.
|
|
278
303
|
*/
|
|
279
304
|
export declare function orgPlatformReadinessFor(org: DeclaredOrg, base: OrgPlatformReadiness, orgRecordedIds: Readonly<Record<string, string>>, heldRefs: ReadonlySet<string>): OrgPlatformReadiness;
|
|
305
|
+
/**
|
|
306
|
+
* The public origin of the owner company's jobs scheduler, which a company on shared services registers its
|
|
307
|
+
* entries on: what the owner's `coolify:own-jobs-route` step recorded, or `undefined` before it ran.
|
|
308
|
+
*/
|
|
309
|
+
export declare function ownerJobsPublicUrl(ownerRecordedIds: Readonly<Record<string, string>> | undefined): string | undefined;
|
|
280
310
|
/** The own org's held-key refs `heldKeys` resolves right now. A resolver that throws counts as not held, and its error goes nowhere. */
|
|
281
311
|
export declare function heldOwnKeyRefs(heldKeys: HeldKeyResolver, org: Pick<DeclaredOrg, 'slug'>): Promise<Set<string>>;
|
|
282
312
|
/** {@link agoraHostValuesFromPlatform}'s counterpart for an own org: every value is the org's own, null until recorded. */
|
|
@@ -6,6 +6,7 @@ import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_
|
|
|
6
6
|
import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, ownAuthApplied } from './login-host.js';
|
|
7
7
|
import { oikosIngressApplied } from './oikos-ingress.js';
|
|
8
8
|
import { ownHostnames } from './own-hostnames.js';
|
|
9
|
+
import { JOBS_PUBLIC_URL_SCOPE_KEY } from './own-jobs-route.js';
|
|
9
10
|
import { OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget } from './own-services.js';
|
|
10
11
|
import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
|
|
11
12
|
import { SIGN_IN_LOGIN_APP_SCOPE_KEY, inAppSignIn } from './sign-in.js';
|
|
@@ -223,7 +224,8 @@ export function orgPlatformReadinessFor(org, base, orgRecordedIds, heldRefs) {
|
|
|
223
224
|
ai: base.missingKeys.ai,
|
|
224
225
|
jobs: missing(jobsApplied, refs.jobsAdminToken),
|
|
225
226
|
};
|
|
226
|
-
const { jobsUrlMissing: _shared, ...rest } = base;
|
|
227
|
+
const { jobsUrlMissing: _shared, jobsPublicUrl: _sharedRoute, ...rest } = base;
|
|
228
|
+
const ownJobsUrl = orgRecordedIds[JOBS_PUBLIC_URL_SCOPE_KEY];
|
|
227
229
|
return {
|
|
228
230
|
...rest,
|
|
229
231
|
authReady,
|
|
@@ -232,6 +234,7 @@ export function orgPlatformReadinessFor(org, base, orgRecordedIds, heldRefs) {
|
|
|
232
234
|
filesUrl: filesApplied ? (filesUrl ?? null) : null,
|
|
233
235
|
jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
|
|
234
236
|
...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
|
|
237
|
+
...(jobsApplied && ownJobsUrl ? { jobsPublicUrl: ownJobsUrl } : {}),
|
|
235
238
|
callsReady: ownStepRecorded(orgRecordedIds, org, 'own-sfu'),
|
|
236
239
|
// The MX name is the shared relay's, `mx.<platform apex>`: no platform mail step is needed.
|
|
237
240
|
// The apex is the company's mail zone: its domain when mail runs on it, else oikos.host.
|
|
@@ -244,6 +247,14 @@ export function orgPlatformReadinessFor(org, base, orgRecordedIds, heldRefs) {
|
|
|
244
247
|
missingKeys,
|
|
245
248
|
};
|
|
246
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* The public origin of the owner company's jobs scheduler, which a company on shared services registers its
|
|
252
|
+
* entries on: what the owner's `coolify:own-jobs-route` step recorded, or `undefined` before it ran.
|
|
253
|
+
*/
|
|
254
|
+
export function ownerJobsPublicUrl(ownerRecordedIds) {
|
|
255
|
+
const url = ownerRecordedIds?.[JOBS_PUBLIC_URL_SCOPE_KEY];
|
|
256
|
+
return url?.startsWith('https://') ? url : undefined;
|
|
257
|
+
}
|
|
247
258
|
/** The own org's held-key refs `heldKeys` resolves right now. A resolver that throws counts as not held, and its error goes nowhere. */
|
|
248
259
|
export async function heldOwnKeyRefs(heldKeys, org) {
|
|
249
260
|
const held = new Set();
|
|
@@ -496,6 +496,8 @@ export interface JobsHostInput {
|
|
|
496
496
|
export declare function provisionJobsHost(scope: StepScope, deps: JobsHostDeps, target: ServiceTarget, input: JobsHostInput): Promise<{
|
|
497
497
|
resourceId?: string;
|
|
498
498
|
}>;
|
|
499
|
+
/** The Coolify project of the platform's sign-in stack, the broker included. */
|
|
500
|
+
export declare const PLATFORM_AUTH_PROJECT = "platform";
|
|
499
501
|
/**
|
|
500
502
|
* Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
|
|
501
503
|
* kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
|
|
@@ -5,7 +5,7 @@ import { VERSIONS, coolifyImageFor, floatingTagBlocked, pinnedRef } from '../boo
|
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
6
|
import { ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
7
7
|
import { applyArchonBlueGreen } from './archon-blue-green.js';
|
|
8
|
-
import { ARCHON_REPO, archonSubject, healthTokenName, tunnelOriginFor, } from './blue-green.js';
|
|
8
|
+
import { ARCHON_REPO, archonSubject, healthTokenName, pinKeyName, tunnelOriginFor, } from './blue-green.js';
|
|
9
9
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
|
|
10
10
|
import { recordBuiltCommit } from './build-record.js';
|
|
11
11
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
@@ -215,6 +215,8 @@ export class FoundryPlatformStepExecutor {
|
|
|
215
215
|
coolify: archon.coolify,
|
|
216
216
|
healthToken: async () => (await archon.heldKeys?.resolve(sharedSecretRef(platform.ownerOrgId, healthTokenName(subject.id)))) ?? null,
|
|
217
217
|
secret: (value) => archon.runtime?.onSecret?.(value),
|
|
218
|
+
pinRef: sharedSecretRef(platform.ownerOrgId, pinKeyName(subject.id)),
|
|
219
|
+
...(archon.heldKeys ? { heldKeys: archon.heldKeys } : {}),
|
|
218
220
|
primaryUuid,
|
|
219
221
|
primaryBlueAppId,
|
|
220
222
|
runtime: this.runtime,
|
|
@@ -1272,7 +1274,8 @@ export async function provisionJobsHost(scope, deps, target, input) {
|
|
|
1272
1274
|
// ---------------------------------------------------------------------------
|
|
1273
1275
|
// #168: the platform's own sign-in (ZITADEL)
|
|
1274
1276
|
// ---------------------------------------------------------------------------
|
|
1275
|
-
|
|
1277
|
+
/** The Coolify project of the platform's sign-in stack, the broker included. */
|
|
1278
|
+
export const PLATFORM_AUTH_PROJECT = 'platform';
|
|
1276
1279
|
/**
|
|
1277
1280
|
* Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
|
|
1278
1281
|
* kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
|
|
@@ -4027,6 +4030,8 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
|
|
|
4027
4030
|
{ name: 'org-apps', path: '/api/orgs/*/apps' },
|
|
4028
4031
|
{ name: 'org-requests', path: '/api/orgs/*/requests' },
|
|
4029
4032
|
{ name: 'github-releases', path: '/api/github/releases' },
|
|
4033
|
+
{ name: 'github-install-callback', path: '/api/github/install/callback' },
|
|
4034
|
+
{ name: 'org-github', path: '/api/orgs/*/github' },
|
|
4030
4035
|
];
|
|
4031
4036
|
const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
|
|
4032
4037
|
const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { agoraHostnameFor } from './agora.js';
|
|
2
2
|
import { bouleHostnameFor } from './boule.js';
|
|
3
3
|
import { appHostname, cmsStudioHostnameFor, entryHostnames } from './hostnames.js';
|
|
4
|
+
import { workspaceAuthEntryOf, workspaceClientHosts } from './workspace-client.js';
|
|
4
5
|
/** The page `@wtfalch/auth` apps land on after ZITADEL ends the session. */
|
|
5
6
|
export const SIGNED_OUT_PATH = '/auth/signed-out';
|
|
6
7
|
/** Both landing addresses of a host: its root and its signed-out page. */
|
|
@@ -31,7 +32,12 @@ export function zitadelAppPostLogoutAppliedScopeKey(appKey) {
|
|
|
31
32
|
*/
|
|
32
33
|
export function companyAppPostLogoutUris(org, appKey) {
|
|
33
34
|
const authEntry = org.hosting.entries.find((e) => e.key === appKey && e.uses?.auth);
|
|
34
|
-
const
|
|
35
|
+
const workspaceEntry = authEntry ? undefined : workspaceAuthEntryOf(org, appKey);
|
|
36
|
+
const hosts = authEntry
|
|
37
|
+
? entryHostnames(org, authEntry)
|
|
38
|
+
: workspaceEntry
|
|
39
|
+
? workspaceClientHosts(org, workspaceEntry)
|
|
40
|
+
: null;
|
|
35
41
|
if (appKey === 'agora')
|
|
36
42
|
return [`https://${agoraHostnameFor(org)}/`];
|
|
37
43
|
const hostname = appKey === 'cms' && org.site.enabled
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type DeclaredOrg, type PrivatePage } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* Private pages (`HostingEntry.privatePages`): a path of an app that only the company's people may open. Each is
|
|
4
|
+
* a path-scoped Cloudflare Access application (`<host><path>`, a self-hosted app whose domain carries a path)
|
|
5
|
+
* made by `planGates` like any other gate, with the company sign-in claim, over the entry's live hosts (`pp-`)
|
|
6
|
+
* and over its workspace host (`wp-`). Cloudflare applies the most specific application, so the page's gate
|
|
7
|
+
* wins over the workspace's whole-host gate, and a tester who may open the workspace is refused here.
|
|
8
|
+
*
|
|
9
|
+
* Foundry cannot turn a role into people: `roles` are the company's role ids, and the host that declares the
|
|
10
|
+
* org puts the email addresses of the people holding them in `people`. The gate admits the owners and those.
|
|
11
|
+
*/
|
|
12
|
+
export type PrivatePageScope = 'live' | 'workspace';
|
|
13
|
+
/** The gate key of one private page: a hash of the scope, entry key and path, so it fits a DNS label, never clashes with `ws-<key>` or a declared gate key, and needs no escaping. */
|
|
14
|
+
export declare function privatePageGateKey(scope: PrivatePageScope, entryKey: string, path: string): string;
|
|
15
|
+
/** Who passes a private page: the owners and the page's `people`, lowercased, distinct, sorted. */
|
|
16
|
+
export declare function privatePagePeople(org: Pick<DeclaredOrg, 'owners'>, page: PrivatePage): string[];
|
|
17
|
+
export interface PrivatePageGate {
|
|
18
|
+
entry: string;
|
|
19
|
+
hostnames: string[];
|
|
20
|
+
people: string[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The private page gates by key: the live ones from `org` (an entry that is not live has no live host) and the
|
|
24
|
+
* workspace ones from `declared` (the org with its dark entries).
|
|
25
|
+
*/
|
|
26
|
+
export declare function privatePageGates(org: DeclaredOrg, declared: DeclaredOrg): Map<string, PrivatePageGate>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { normalizeTesters } from '../org.js';
|
|
3
|
+
import { entryHostnames, workspaceHost } from './hostnames.js';
|
|
4
|
+
import { companyPeople, workspaceEntries } from './workspace.js';
|
|
5
|
+
/** The gate key of one private page: a hash of the scope, entry key and path, so it fits a DNS label, never clashes with `ws-<key>` or a declared gate key, and needs no escaping. */
|
|
6
|
+
export function privatePageGateKey(scope, entryKey, path) {
|
|
7
|
+
const hash = createHash('sha256').update(`${scope}\0${entryKey}\0${path}`).digest('hex');
|
|
8
|
+
return `${scope === 'live' ? 'pp' : 'wp'}-${hash.slice(0, 16)}`;
|
|
9
|
+
}
|
|
10
|
+
/** Who passes a private page: the owners and the page's `people`, lowercased, distinct, sorted. */
|
|
11
|
+
export function privatePagePeople(org, page) {
|
|
12
|
+
return normalizeTesters([...companyPeople(org), ...(page.people ?? [])]).sort();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The private page gates by key: the live ones from `org` (an entry that is not live has no live host) and the
|
|
16
|
+
* workspace ones from `declared` (the org with its dark entries).
|
|
17
|
+
*/
|
|
18
|
+
export function privatePageGates(org, declared) {
|
|
19
|
+
const gates = new Map();
|
|
20
|
+
if (!org.hosting.enabled)
|
|
21
|
+
return gates;
|
|
22
|
+
const add = (scope, entry, hosts) => {
|
|
23
|
+
if (hosts.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
for (const page of entry.privatePages ?? []) {
|
|
26
|
+
gates.set(privatePageGateKey(scope, entry.key, page.path), {
|
|
27
|
+
entry: entry.key,
|
|
28
|
+
hostnames: hosts.map((host) => `${host}${page.path}`).sort(),
|
|
29
|
+
people: privatePagePeople(declared, page),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
for (const entry of org.hosting.entries)
|
|
34
|
+
add('live', entry, entryHostnames(org, entry));
|
|
35
|
+
for (const entry of workspaceEntries(declared)) {
|
|
36
|
+
add('workspace', entry, [workspaceHost(declared.slug, entry.key)]);
|
|
37
|
+
}
|
|
38
|
+
return gates;
|
|
39
|
+
}
|
|
@@ -26,6 +26,8 @@ export declare const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = "cloudflare_tunn
|
|
|
26
26
|
* last written for, comma-joined. Recorded only once every record is in place.
|
|
27
27
|
*/
|
|
28
28
|
export declare const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = "cloudflare_tunnel_oikos_hostnames";
|
|
29
|
+
/** The same for the entry names in the platform's companyincluded.app zone ({@link entryAppsHostnames}). */
|
|
30
|
+
export declare const CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY = "cloudflare_tunnel_apps_hostnames";
|
|
29
31
|
/**
|
|
30
32
|
* The run label every customer hosting-entry container gets. A run label overrides the image's own
|
|
31
33
|
* `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
|
|
@@ -92,9 +94,18 @@ export declare function sameLabelSets(a: string, b: string): boolean;
|
|
|
92
94
|
* The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
|
|
93
95
|
* `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
|
|
94
96
|
*/
|
|
95
|
-
export declare function entryOikosHostnames(org: DeclaredOrg): string[];
|
|
97
|
+
export declare function entryOikosHostnames(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string[];
|
|
96
98
|
/**
|
|
97
99
|
* Whether `name` is one the company may write in the platform's `oikos.host` zone: exactly its own
|
|
98
100
|
* `<slug>.oikos.host`. A company writes no other company's name, whatever an entry declares.
|
|
99
101
|
*/
|
|
100
102
|
export declare function ownsOikosName(org: DeclaredOrg, name: string): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* The entry names that live in the platform's `companyincluded.app` zone: an entry's `hostedName`, and the
|
|
105
|
+
* workspace host of each server workspace that is applied. Sorted.
|
|
106
|
+
*/
|
|
107
|
+
export declare function entryAppsHostnames(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string[];
|
|
108
|
+
/** Whether `name` is one this org may write in the `companyincluded.app` zone: a hosted name or its workspace host. */
|
|
109
|
+
export declare const ownsAppsZoneName: (org: DeclaredOrg, name: string) => boolean;
|
|
110
|
+
/** Whether `name` is one label directly under `companyincluded.app`. */
|
|
111
|
+
export declare function isAppsEntryName(name: string): boolean;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { APPS_DOMAIN } from '../org.js';
|
|
2
3
|
import { BUILTIN_APPS, PRIMARY_SERVER_SCOPE_KEY, balancerApplied, builtinSubject, lbAppName, lbOrigin, liveAppNameFor, } from './blue-green.js';
|
|
3
4
|
import { entryHostnames, wildcardHostname } from './hostnames.js';
|
|
4
5
|
import { NO_DOMAIN_ZONE } from './own-hostnames.js';
|
|
5
6
|
import { appNameFor } from './platform-diff.js';
|
|
7
|
+
import { ownsWorkspaceName, workspaceRoutes } from './workspace.js';
|
|
6
8
|
/**
|
|
7
9
|
* The label every routed container carries, with the install's token as its value. Traefik is told
|
|
8
10
|
* (`--providers.docker.constraints=Label(`foundry.edge`,`<token>`)`) to route only containers that carry
|
|
@@ -29,6 +31,8 @@ export const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = 'cloudflare_tunnel_entry
|
|
|
29
31
|
* last written for, comma-joined. Recorded only once every record is in place.
|
|
30
32
|
*/
|
|
31
33
|
export const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_oikos_hostnames';
|
|
34
|
+
/** The same for the entry names in the platform's companyincluded.app zone ({@link entryAppsHostnames}). */
|
|
35
|
+
export const CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_apps_hostnames';
|
|
32
36
|
/**
|
|
33
37
|
* The run label every customer hosting-entry container gets. A run label overrides the image's own
|
|
34
38
|
* `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
|
|
@@ -65,6 +69,8 @@ export function entryRoutes(org, recorded = {}) {
|
|
|
65
69
|
for (const entry of org.hosting.entries) {
|
|
66
70
|
if (entry.workerApp)
|
|
67
71
|
continue; // Served by its Worker route, not the tunnel.
|
|
72
|
+
if (entry.live === false)
|
|
73
|
+
continue; // Only its workspace exists (below).
|
|
68
74
|
// A blue/green entry is reached through its balancer, which each server's connector finds on its own
|
|
69
75
|
// network, once the balancer exists on the primary. Until then it keeps the origin it had.
|
|
70
76
|
const target = !entry.blueGreen
|
|
@@ -81,6 +87,10 @@ export function entryRoutes(org, recorded = {}) {
|
|
|
81
87
|
if (wildcardName && !wildcard)
|
|
82
88
|
wildcard = { hostname: wildcardName, service };
|
|
83
89
|
}
|
|
90
|
+
for (const route of workspaceRoutes(org, recorded)) {
|
|
91
|
+
if (!routes.some((r) => r.hostname === route.hostname))
|
|
92
|
+
routes.push(route);
|
|
93
|
+
}
|
|
84
94
|
if (wildcard)
|
|
85
95
|
routes.push(wildcard);
|
|
86
96
|
return routes;
|
|
@@ -171,8 +181,8 @@ export function sameLabelSets(a, b) {
|
|
|
171
181
|
* The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
|
|
172
182
|
* `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
|
|
173
183
|
*/
|
|
174
|
-
export function entryOikosHostnames(org) {
|
|
175
|
-
return entryRoutes(org)
|
|
184
|
+
export function entryOikosHostnames(org, recorded = {}) {
|
|
185
|
+
return entryRoutes(org, recorded)
|
|
176
186
|
.map((r) => r.hostname)
|
|
177
187
|
.filter((h) => h.endsWith(`.${NO_DOMAIN_ZONE}`))
|
|
178
188
|
.sort();
|
|
@@ -184,3 +194,23 @@ export function entryOikosHostnames(org) {
|
|
|
184
194
|
export function ownsOikosName(org, name) {
|
|
185
195
|
return name.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`;
|
|
186
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The entry names that live in the platform's `companyincluded.app` zone: an entry's `hostedName`, and the
|
|
199
|
+
* workspace host of each server workspace that is applied. Sorted.
|
|
200
|
+
*/
|
|
201
|
+
export function entryAppsHostnames(org, recorded = {}) {
|
|
202
|
+
return entryRoutes(org, recorded)
|
|
203
|
+
.map((r) => r.hostname)
|
|
204
|
+
.filter((name) => isAppsEntryName(name) || ownsWorkspaceName(org, name))
|
|
205
|
+
.sort();
|
|
206
|
+
}
|
|
207
|
+
/** Whether `name` is one this org may write in the `companyincluded.app` zone: a hosted name or its workspace host. */
|
|
208
|
+
export const ownsAppsZoneName = (org, name) => isAppsEntryName(name) || ownsWorkspaceName(org, name);
|
|
209
|
+
/** Whether `name` is one label directly under `companyincluded.app`. */
|
|
210
|
+
export function isAppsEntryName(name) {
|
|
211
|
+
const suffix = `.${APPS_DOMAIN}`;
|
|
212
|
+
const lower = name.toLowerCase();
|
|
213
|
+
return (lower.endsWith(suffix) &&
|
|
214
|
+
lower.length > suffix.length &&
|
|
215
|
+
!lower.slice(0, -suffix.length).includes('.'));
|
|
216
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReleaseDeps } from '../release/run.js';
|
|
2
2
|
import type { ReleaseStore } from '../release/state.js';
|
|
3
|
-
import { type BlueGreenSubject } from './blue-green.js';
|
|
3
|
+
import { type BlueGreenSubject, type PinHeldKeys } from './blue-green.js';
|
|
4
4
|
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
5
5
|
import type { CoolifyPort } from './executor.js';
|
|
6
6
|
/** The recorded ids a release run reads and writes: an org's apply context, or the platform's. */
|
|
@@ -21,13 +21,17 @@ export interface ReleaseHost {
|
|
|
21
21
|
export interface ReleaseHostInput {
|
|
22
22
|
subject: BlueGreenSubject;
|
|
23
23
|
context: ReleaseContext;
|
|
24
|
-
coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
|
|
24
|
+
coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
|
|
25
25
|
listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
|
|
26
26
|
};
|
|
27
27
|
/** Reads the entry's health token (`sharedSecretRef(org.id, healthTokenName(id))`). Never mints. */
|
|
28
28
|
healthToken: () => Promise<string | null>;
|
|
29
29
|
/** Registers a value for redaction before it is written anywhere. */
|
|
30
30
|
secret: (value: string) => void;
|
|
31
|
+
/** The held ref of the balancer's pin key (`sharedSecretRef(owner, pinKeyName(id))`). */
|
|
32
|
+
pinRef: string;
|
|
33
|
+
/** The held store. Without it the pin key cannot be read, so a check through the balancer is inconclusive. */
|
|
34
|
+
heldKeys?: PinHeldKeys;
|
|
31
35
|
primaryUuid: string;
|
|
32
36
|
/** The Coolify app of the blue copy on the primary (the entry's `coolify:app`, or the running archon app). */
|
|
33
37
|
primaryBlueAppId: string;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import { checkColorAddress, readReleaseHealth } from '../release/health.js';
|
|
2
3
|
import { runRelease } from '../release/run.js';
|
|
3
4
|
import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
|
|
4
|
-
import { DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, parseWeights, reportingEnv, } from './blue-green.js';
|
|
5
|
+
import { ACTIONS_KEY_ENV, DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, mintPinKey, parseWeights, readPinKey, reportingEnv, } from './blue-green.js';
|
|
5
6
|
import { waitForNewDeployment } from './deploy-health.js';
|
|
6
7
|
import { isNotFoundError } from './executor.js';
|
|
7
8
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
@@ -41,13 +42,20 @@ export function createReleaseHost(input) {
|
|
|
41
42
|
* Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
|
|
42
43
|
* header router. The balancer routes the pinned request to that color whatever its weight.
|
|
43
44
|
*/
|
|
44
|
-
const
|
|
45
|
+
const pinInput = () => ({
|
|
46
|
+
id,
|
|
47
|
+
ref: input.pinRef,
|
|
48
|
+
recorded,
|
|
49
|
+
secret: input.secret,
|
|
50
|
+
heldKeys: input.heldKeys ?? { resolve: async () => null },
|
|
51
|
+
});
|
|
52
|
+
const addressOf = async (color) => {
|
|
45
53
|
const hostname = subject.hostnames.find((name) => !name.startsWith('*'));
|
|
46
54
|
if (!hostname)
|
|
47
55
|
throw new Error(`${input.label}: the app has no public hostname to check through`);
|
|
48
|
-
const pinKey =
|
|
56
|
+
const pinKey = await readPinKey(pinInput());
|
|
49
57
|
if (!pinKey)
|
|
50
|
-
throw new Error(`${input.label}: the balancer's pin key is not
|
|
58
|
+
throw new Error(`${input.label}: the balancer's pin key is not held`);
|
|
51
59
|
return {
|
|
52
60
|
address: `https://${hostname}`,
|
|
53
61
|
headers: { [LB_COLOR_HEADER]: color, [LB_PIN_HEADER]: pinKey },
|
|
@@ -101,6 +109,18 @@ export function createReleaseHost(input) {
|
|
|
101
109
|
if (envs.length > 0) {
|
|
102
110
|
await coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
|
|
103
111
|
}
|
|
112
|
+
// A key removed from blue (a rotated-out credential) must not stay on the slot.
|
|
113
|
+
if (appId !== input.primaryBlueAppId) {
|
|
114
|
+
const keep = new Set([...source.map((e) => e.key), ...envs.map((e) => e.key)]);
|
|
115
|
+
for (const env of await coolify.listEnvs(appId)) {
|
|
116
|
+
if (env.uuid &&
|
|
117
|
+
!keep.has(env.key) &&
|
|
118
|
+
!env.key.startsWith('REPORTING_') &&
|
|
119
|
+
env.key !== ACTIONS_KEY_ENV) {
|
|
120
|
+
await coolify.deleteEnv(appId, env.uuid);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
104
124
|
}
|
|
105
125
|
catch {
|
|
106
126
|
// A Coolify error can echo the request body, which holds the token.
|
|
@@ -129,7 +149,7 @@ export function createReleaseHost(input) {
|
|
|
129
149
|
return null;
|
|
130
150
|
input.secret(token);
|
|
131
151
|
return readReleaseHealth({
|
|
132
|
-
...addressOf(healthColor()),
|
|
152
|
+
...(await addressOf(healthColor())),
|
|
133
153
|
token,
|
|
134
154
|
...range,
|
|
135
155
|
...(input.fetch ? { fetchImpl: input.fetch } : {}),
|
|
@@ -146,9 +166,10 @@ export function createReleaseHost(input) {
|
|
|
146
166
|
return {
|
|
147
167
|
status,
|
|
148
168
|
address: await checkColorAddress({
|
|
149
|
-
...addressOf(color),
|
|
169
|
+
...(await addressOf(color)),
|
|
150
170
|
healthPath: subject.healthPath,
|
|
151
|
-
|
|
171
|
+
// A server answers JSON on its health path; a static app answers HTML, which must not be an Access page.
|
|
172
|
+
expectJson: subject.reporting,
|
|
152
173
|
...(input.fetch ? { fetchImpl: input.fetch } : {}),
|
|
153
174
|
}),
|
|
154
175
|
};
|
|
@@ -156,8 +177,9 @@ export function createReleaseHost(input) {
|
|
|
156
177
|
async effect(effect) {
|
|
157
178
|
if (effect.kind === 'deploy')
|
|
158
179
|
await deployTo(effect.color, effect.digest);
|
|
159
|
-
else
|
|
180
|
+
else if (effect.kind === 'stop')
|
|
160
181
|
await stop(effect.color);
|
|
182
|
+
// deploy_stage is executed by the stage deploy task (F4); until then it is passed on and ignored here.
|
|
161
183
|
},
|
|
162
184
|
};
|
|
163
185
|
return {
|
|
@@ -187,6 +209,12 @@ export function createReleaseHost(input) {
|
|
|
187
209
|
for (const uuid of servers) {
|
|
188
210
|
await input.renew();
|
|
189
211
|
await applyBlueGreenWeights({
|
|
212
|
+
pinKey: () => mintPinKey({
|
|
213
|
+
...pinInput(),
|
|
214
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
215
|
+
persist: () => context.persist(),
|
|
216
|
+
generate: () => randomBytes(32).toString('hex'),
|
|
217
|
+
}),
|
|
190
218
|
coolify,
|
|
191
219
|
recorded,
|
|
192
220
|
record: (key, value) => context.recordScopeId(key, value),
|
package/dist/plan/stage-env.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
import { type StageKind } from './stage-isolation.js';
|
|
1
|
+
import { type LiveSignIn, type StageKind } from './stage-isolation.js';
|
|
2
|
+
/** A stage with its own sign-in: a workspace. Its values are the stage's, never the live app's. */
|
|
3
|
+
export interface StageSignIn {
|
|
4
|
+
/** Issuer, organisation id, client id and, with workspaces, the broker URL and the stage's own key. */
|
|
5
|
+
env: ReadonlyArray<{
|
|
6
|
+
key: string;
|
|
7
|
+
value: string;
|
|
8
|
+
}>;
|
|
9
|
+
/** Stage signs in the company's test customers organisation. */
|
|
10
|
+
customers: boolean;
|
|
11
|
+
/** The redirect URI registered for the stage client. */
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
/** What stage must never hold. */
|
|
14
|
+
live: LiveSignIn;
|
|
15
|
+
}
|
|
16
|
+
/** The names a stage's own sign-in sets; a change to one of them redeploys stage. */
|
|
17
|
+
export declare const STAGE_SIGN_IN_NAMES: readonly string[];
|
|
2
18
|
export interface StageEnvInput {
|
|
3
19
|
kind: StageKind;
|
|
4
20
|
/** Where the stage app is reached: its own container address, since stage has no hostname. */
|
|
@@ -22,6 +38,8 @@ export interface StageEnvInput {
|
|
|
22
38
|
is_preview?: boolean;
|
|
23
39
|
}>;
|
|
24
40
|
mint: () => string;
|
|
41
|
+
/** Stage's own sign-in. Its values override any copied from the live app; its cookie secret and app key are minted. */
|
|
42
|
+
signIn?: StageSignIn;
|
|
25
43
|
}
|
|
26
44
|
export interface StageEnv {
|
|
27
45
|
env: Array<{
|
package/dist/plan/stage-env.js
CHANGED
|
@@ -5,9 +5,26 @@ const WAITS_FOR_STAGE_SERVICE = new Set([
|
|
|
5
5
|
'AI_URL',
|
|
6
6
|
'JOBS_SCHEDULER_URL',
|
|
7
7
|
'ANALYTICS_URL',
|
|
8
|
+
'WORKSPACES_URL',
|
|
9
|
+
'MCP_ISSUER',
|
|
8
10
|
]);
|
|
9
11
|
/** Secrets stage mints for itself, when the app uses them. A copy of prod's value would be a prod credential. */
|
|
10
12
|
const MINTED = ['AUTH_COOKIE_SECRET', 'KEYSTORE_KEK'];
|
|
13
|
+
/** The names a stage's own sign-in sets; a change to one of them redeploys stage. */
|
|
14
|
+
export const STAGE_SIGN_IN_NAMES = [
|
|
15
|
+
'AUTH_ISSUER',
|
|
16
|
+
'AUTH_ORGANIZATION_ID',
|
|
17
|
+
'AUTH_CLIENT_ID',
|
|
18
|
+
'AUTH_APP_KEY',
|
|
19
|
+
'AUTH_COOKIE_SECRET',
|
|
20
|
+
'WORKSPACES_URL',
|
|
21
|
+
'WORKSPACES_API_KEY',
|
|
22
|
+
'MCP_AUDIENCE',
|
|
23
|
+
'MCP_RESOURCE',
|
|
24
|
+
'MCP_ISSUER',
|
|
25
|
+
];
|
|
26
|
+
/** The shortest `AUTH_APP_KEY` the broker accepts. */
|
|
27
|
+
const MIN_APP_KEY_LENGTH = 32;
|
|
11
28
|
/**
|
|
12
29
|
* The env of a stage app, built from the kind's allowlist and never from a copy of the live app's env. Every
|
|
13
30
|
* name is allowed and none is denied, or this throws a fixed text with the name.
|
|
@@ -41,10 +58,17 @@ export function buildStageEnv(input) {
|
|
|
41
58
|
set(key, value);
|
|
42
59
|
}
|
|
43
60
|
}
|
|
61
|
+
for (const { key, value } of input.signIn?.env ?? [])
|
|
62
|
+
set(key, value);
|
|
44
63
|
for (const key of MINTED) {
|
|
45
|
-
|
|
64
|
+
const wanted = prod.has(key) || (input.signIn !== undefined && key === 'AUTH_COOKIE_SECRET');
|
|
65
|
+
if (wanted && isAllowed(kind, key))
|
|
46
66
|
set(key, existing.get(key) ?? input.mint());
|
|
47
67
|
}
|
|
68
|
+
if (input.signIn) {
|
|
69
|
+
const kept = existing.get('AUTH_APP_KEY');
|
|
70
|
+
set('AUTH_APP_KEY', kept && kept.length >= MIN_APP_KEY_LENGTH ? kept : input.mint());
|
|
71
|
+
}
|
|
48
72
|
const absent = STAGE_ALLOWLIST[kind]
|
|
49
73
|
.filter((name) => !name.endsWith('*') && prod.has(name) && !env.has(name))
|
|
50
74
|
.sort();
|
|
@@ -37,6 +37,22 @@ export interface StageProbePort {
|
|
|
37
37
|
syncKey: string;
|
|
38
38
|
projectId: string;
|
|
39
39
|
}): Promise<boolean>;
|
|
40
|
+
/** Uses the stage workspaces key on the live customers organisation's workspaces in the broker. */
|
|
41
|
+
workspacesRejects?(input: {
|
|
42
|
+
url: string;
|
|
43
|
+
apiKey: string;
|
|
44
|
+
organisationId: string;
|
|
45
|
+
}): Promise<boolean>;
|
|
46
|
+
/**
|
|
47
|
+
* Starts a real authorization request at the issuer for the live customers organisation with the stage client
|
|
48
|
+
* id and the redirect URI registered for that client, and answers `true` when the live sign-in refuses it.
|
|
49
|
+
*/
|
|
50
|
+
signInRejects?(input: {
|
|
51
|
+
issuer: string;
|
|
52
|
+
clientId: string;
|
|
53
|
+
organisationId: string;
|
|
54
|
+
redirectUri: string;
|
|
55
|
+
}): Promise<boolean>;
|
|
40
56
|
}
|
|
41
57
|
/** One probe. `run` is `null` when the host has no port for it: the probe is then reported as not run. */
|
|
42
58
|
export interface StageProbe {
|
|
@@ -52,6 +68,10 @@ export interface StageProbeInput {
|
|
|
52
68
|
prod: ReadonlyMap<string, string>;
|
|
53
69
|
/** Whether stage has a database: if no database probe can be built, one is kept as not run. */
|
|
54
70
|
database?: boolean;
|
|
71
|
+
/** The live customers organisation's id, where the company has one: the sign-in probes aim at it. */
|
|
72
|
+
liveCustomersOrgId?: string;
|
|
73
|
+
/** The redirect URI registered for the stage client: the sign-in probe sends it. */
|
|
74
|
+
signInRedirectUri?: string;
|
|
55
75
|
}
|
|
56
76
|
/**
|
|
57
77
|
* The probes for what stage holds: its database role against production's database host, and its files, ai and
|
|
@@ -77,6 +77,30 @@ export function buildStageProbes(input) {
|
|
|
77
77
|
run: call ? () => call({ url: jobsUrl, syncKey, projectId }) : null,
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
const customersOrgId = input.liveCustomersOrgId;
|
|
81
|
+
const workspacesUrl = stage.get('WORKSPACES_URL');
|
|
82
|
+
const workspacesKey = stage.get('WORKSPACES_API_KEY');
|
|
83
|
+
if (customersOrgId && workspacesUrl && workspacesKey) {
|
|
84
|
+
const call = port?.workspacesRejects?.bind(port);
|
|
85
|
+
probes.push({
|
|
86
|
+
what: 'workspaces:stage-key-on-live-customers',
|
|
87
|
+
run: call
|
|
88
|
+
? () => call({ url: workspacesUrl, apiKey: workspacesKey, organisationId: customersOrgId })
|
|
89
|
+
: null,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const issuer = stage.get('AUTH_ISSUER');
|
|
93
|
+
const clientId = stage.get('AUTH_CLIENT_ID');
|
|
94
|
+
const redirectUri = input.signInRedirectUri;
|
|
95
|
+
if (customersOrgId && issuer && clientId && redirectUri) {
|
|
96
|
+
const call = port?.signInRejects?.bind(port);
|
|
97
|
+
probes.push({
|
|
98
|
+
what: 'sign-in:stage-client-on-live-customers',
|
|
99
|
+
run: call
|
|
100
|
+
? () => call({ issuer, clientId, organisationId: customersOrgId, redirectUri })
|
|
101
|
+
: null,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
80
104
|
return probes;
|
|
81
105
|
}
|
|
82
106
|
/** Runs every probe once, each under a timeout. A throw and a timeout count as failed, never as rejected. */
|