@wtfalch/foundry 0.46.0 → 0.47.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/build-json.d.ts +28 -0
- package/dist/build-json.js +95 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0058_app_requests.sql +32 -0
- package/dist/org.d.ts +31 -5
- package/dist/org.js +152 -125
- package/dist/plan/diff.js +6 -5
- package/dist/plan/executor.js +8 -8
- package/dist/plan/health-targets.js +1 -2
- package/dist/plan/hostnames.d.ts +2 -2
- package/dist/plan/hostnames.js +6 -6
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/plan-apply.d.ts +30 -1
- package/dist/plan/plan-apply.js +70 -1
- package/dist/plan/platform-providers.js +3 -0
- package/dist/plan/providers-readers.js +15 -4
- package/dist/plan/worker-app.js +3 -1
- package/dist/refuse-org.d.ts +7 -0
- package/dist/refuse-org.js +10 -0
- package/dist/requested-changes.d.ts +50 -4
- package/dist/requested-changes.js +9 -1
- package/dist/store/postgres.d.ts +5 -1
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +72 -0
- package/dist/store/schema.js +4 -0
- package/dist/store.d.ts +24 -1
- package/dist/worker-app-config.d.ts +8 -2
- package/dist/worker-app-config.js +20 -6
- package/package.json +1 -1
package/dist/plan/diff.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
|
-
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
|
|
2
|
+
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
|
|
3
3
|
import { entryHasDatabase } from '../worker-app-config.js';
|
|
4
4
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
5
5
|
import { planBlueGreen } from './blue-green.js';
|
|
@@ -3161,7 +3161,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
3161
3161
|
// right now, and gating this on `mailHost` let a platform-apex org with an
|
|
3162
3162
|
// already-recorded domain take the unprotected path the moment platform
|
|
3163
3163
|
// mail went back to off.
|
|
3164
|
-
if (entryKey ===
|
|
3164
|
+
if (entryKey === mainEntry(org.hosting)?.key) {
|
|
3165
3165
|
const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
|
|
3166
3166
|
const platformApex = readiness.platformApex;
|
|
3167
3167
|
const platformLettermintProjectId = readiness.platformLettermintProjectId;
|
|
@@ -3473,6 +3473,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
3473
3473
|
.map((id) => id.slice(`coolify:app:${org.id}:`.length));
|
|
3474
3474
|
const declared = new Set([APP_ENTRY_KEY, ...org.hosting.entries.map((e) => e.key)]);
|
|
3475
3475
|
const keys = new Set([...declared, ...recordedKeys]);
|
|
3476
|
+
const mainKey = mainEntry(org.hosting)?.key;
|
|
3476
3477
|
for (const key of keys) {
|
|
3477
3478
|
if (!declared.has(key)) {
|
|
3478
3479
|
// The reader gives a parked app no `enabled` field, so only a running
|
|
@@ -3508,14 +3509,14 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
3508
3509
|
app.step.action.desired = { ...app.step.action.desired, authIssuer };
|
|
3509
3510
|
}
|
|
3510
3511
|
// #491: only the `app` entry's own step runs `ensureAppLettermintMail`
|
|
3511
|
-
// (`executor.ts`'s own `entry
|
|
3512
|
+
// (`executor.ts`'s own `isMainEntry(entry)` gate). Unlike
|
|
3512
3513
|
// `secretSources`/`authIssuer` above, these two are set unconditionally
|
|
3513
3514
|
// -- `null` when no platform is declared, never omitted -- because
|
|
3514
3515
|
// `executor.ts` treats the key being *absent* as a stale plan predating
|
|
3515
3516
|
// this field and refuses to run (review round 2, finding 3): an omitted
|
|
3516
3517
|
// key here would read identically to a stale plan and silently fall
|
|
3517
3518
|
// back to the unprotected pre-#491 path instead.
|
|
3518
|
-
if (key ===
|
|
3519
|
+
if (key === mainKey && app.step && app.step.action.desired.enabled !== false) {
|
|
3519
3520
|
app.step.action.desired = {
|
|
3520
3521
|
...app.step.action.desired,
|
|
3521
3522
|
platformApex: readiness.platformApex,
|
|
@@ -3527,7 +3528,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
3527
3528
|
// edge: blocked, not planned, while edge is not applied.
|
|
3528
3529
|
const appReason = edgeNeeded(readiness, ['zones', 'dns']);
|
|
3529
3530
|
if (appReason &&
|
|
3530
|
-
key ===
|
|
3531
|
+
key === mainKey &&
|
|
3531
3532
|
app.step &&
|
|
3532
3533
|
app.step.action.desired.enabled !== false &&
|
|
3533
3534
|
recordedIds[MAIL_DOMAIN_SCOPE_KEY]) {
|
package/dist/plan/executor.js
CHANGED
|
@@ -4,7 +4,7 @@ import { URL } from 'node:url';
|
|
|
4
4
|
import { coolifyImageFor } from '../bootstrap/versions.js';
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
6
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
7
|
-
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS,
|
|
7
|
+
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, filesCollectionEnvName, heldEnvRef, isMainEntry, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
8
8
|
import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
|
|
9
9
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
|
|
10
10
|
import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
|
|
@@ -211,7 +211,7 @@ export function callsHostnameFor(org) {
|
|
|
211
211
|
*/
|
|
212
212
|
export function healthUrls(org) {
|
|
213
213
|
// One URL per entry that has a public name; an internal entry has none.
|
|
214
|
-
if (org.hosting.enabled &&
|
|
214
|
+
if (org.hosting.enabled && org.hosting.entries.length > 0) {
|
|
215
215
|
return org.hosting.entries.flatMap((entry) => {
|
|
216
216
|
const [hostname] = entryHostnames(org, entry);
|
|
217
217
|
if (!hostname)
|
|
@@ -3298,7 +3298,7 @@ export class FoundryStepExecutor {
|
|
|
3298
3298
|
// actually resolved a mail domain for this org.
|
|
3299
3299
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
3300
3300
|
// second entry sharing the name would delete the first's live token.
|
|
3301
|
-
if (entry
|
|
3301
|
+
if (isMainEntry(entry)) {
|
|
3302
3302
|
await this.ensureAppLettermintMail(appId, org, context,
|
|
3303
3303
|
// Deployed or started above on every path, a created app included
|
|
3304
3304
|
// (#566), so it has read its env before the mail env is written.
|
|
@@ -3718,10 +3718,8 @@ export class FoundryStepExecutor {
|
|
|
3718
3718
|
async pushAppEnv(appId, org, entry, context, authIssuer, secretSources) {
|
|
3719
3719
|
const recorded = context.recordedIds();
|
|
3720
3720
|
const envs = [
|
|
3721
|
-
// Only the
|
|
3722
|
-
...(entry
|
|
3723
|
-
? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
|
|
3724
|
-
: []),
|
|
3721
|
+
// Only the main entry has a public name; the others are internal.
|
|
3722
|
+
...(isMainEntry(entry) ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }] : []),
|
|
3725
3723
|
// #75: read back by the package-side pino sink (not this repo's job,
|
|
3726
3724
|
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
3727
3725
|
// matching volume.
|
|
@@ -4203,7 +4201,9 @@ export class FoundryStepExecutor {
|
|
|
4203
4201
|
const { org, context, mailDomain, self, mailFrom } = args;
|
|
4204
4202
|
const appName = `${org.slug}-app`;
|
|
4205
4203
|
const recorded = context.recordedIds();
|
|
4206
|
-
const appId = self.role === 'app'
|
|
4204
|
+
const appId = self.role === 'app'
|
|
4205
|
+
? self.id
|
|
4206
|
+
: recorded[`coolify:app:${appStepKey(org, mainEntry(org.hosting)?.key)}`];
|
|
4207
4207
|
const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
|
|
4208
4208
|
const peerListEnvs = (id) => !id || id === self.id
|
|
4209
4209
|
? Promise.resolve(null)
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { appEntry } from '../org.js';
|
|
2
1
|
import { agoraHostnameFor } from './agora.js';
|
|
3
2
|
import { bouleHostnameFor } from './boule.js';
|
|
4
3
|
import { callsHostnameFor, cmsHostnameFor } from './executor.js';
|
|
@@ -187,7 +186,7 @@ function hostingEntryTarget(org, entry) {
|
|
|
187
186
|
* differently-written version of the same choice.
|
|
188
187
|
*/
|
|
189
188
|
function appTargets(org) {
|
|
190
|
-
if (org.hosting.enabled &&
|
|
189
|
+
if (org.hosting.enabled && org.hosting.entries.length > 0) {
|
|
191
190
|
return org.hosting.entries.map((entry) => hostingEntryTarget(org, entry));
|
|
192
191
|
}
|
|
193
192
|
if (org.site.enabled) {
|
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
2
|
/**
|
|
3
3
|
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
4
|
-
* domain, `hostnames` is not read: the
|
|
4
|
+
* domain, `hostnames` is not read: the main entry is at `<slug>.oikos.host`
|
|
5
5
|
* and every other entry is internal. `'*'` names nothing here; the wildcard is
|
|
6
6
|
* acted on elsewhere.
|
|
7
7
|
*/
|
|
@@ -10,7 +10,7 @@ export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): s
|
|
|
10
10
|
* The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
|
|
11
11
|
* the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
|
|
12
12
|
* under `oikos.host`, so the free Universal certificate covers it), and
|
|
13
|
-
* `<slug>.oikos.host` for the entry
|
|
13
|
+
* `<slug>.oikos.host` for the main entry.
|
|
14
14
|
*/
|
|
15
15
|
export declare function workerAppHostname(org: DeclaredOrg, entry: HostingEntry): string;
|
|
16
16
|
/**
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isMainEntry, mainEntry } from '../org.js';
|
|
2
2
|
import { workerAppHostnameOf } from '../worker-app-config.js';
|
|
3
3
|
import { agoraHostnameFor } from './agora.js';
|
|
4
4
|
import { bouleHostnameFor } from './boule.js';
|
|
5
5
|
/**
|
|
6
6
|
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
7
|
-
* domain, `hostnames` is not read: the
|
|
7
|
+
* domain, `hostnames` is not read: the main entry is at `<slug>.oikos.host`
|
|
8
8
|
* and every other entry is internal. `'*'` names nothing here; the wildcard is
|
|
9
9
|
* acted on elsewhere.
|
|
10
10
|
*/
|
|
@@ -13,7 +13,7 @@ export function entryHostnames(org, entry) {
|
|
|
13
13
|
if (entry.workerApp)
|
|
14
14
|
return [workerAppHostname(org, entry)];
|
|
15
15
|
if (!org.hosting.usesDomain || !domain) {
|
|
16
|
-
return entry
|
|
16
|
+
return isMainEntry(entry) ? [`${org.slug}.oikos.host`] : [];
|
|
17
17
|
}
|
|
18
18
|
return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
|
|
19
19
|
}
|
|
@@ -21,10 +21,10 @@ export function entryHostnames(org, entry) {
|
|
|
21
21
|
* The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
|
|
22
22
|
* the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
|
|
23
23
|
* under `oikos.host`, so the free Universal certificate covers it), and
|
|
24
|
-
* `<slug>.oikos.host` for the entry
|
|
24
|
+
* `<slug>.oikos.host` for the main entry.
|
|
25
25
|
*/
|
|
26
26
|
export function workerAppHostname(org, entry) {
|
|
27
|
-
return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry
|
|
27
|
+
return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry, entry.workerApp?.host ?? '@');
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
|
|
@@ -43,7 +43,7 @@ export function wildcardHostname(org) {
|
|
|
43
43
|
* and the jobs registration read it.
|
|
44
44
|
*/
|
|
45
45
|
export function appHostname(org) {
|
|
46
|
-
const entry = org.hosting
|
|
46
|
+
const entry = mainEntry(org.hosting);
|
|
47
47
|
const first = entry ? entryHostnames(org, entry)[0] : undefined;
|
|
48
48
|
return first ?? legacyAppHostname(org);
|
|
49
49
|
}
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCO
|
|
|
24
24
|
export type { OrgPostgresMigrationConfirmation } from './org-postgres-migrate.js';
|
|
25
25
|
export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
|
|
26
26
|
export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
|
|
27
|
-
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
27
|
+
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan-apply.js';
|
|
28
28
|
export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
|
|
29
29
|
export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
|
|
30
30
|
export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -11,7 +11,7 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.
|
|
|
11
11
|
export { AdoptRefusedError } from './adopt.js';
|
|
12
12
|
export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, } from './org-postgres-migrate.js';
|
|
13
13
|
export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
|
|
14
|
-
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
14
|
+
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan-apply.js';
|
|
15
15
|
export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
|
|
16
16
|
export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
|
|
17
17
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './platform-providers.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DestroyProgress, PlanApply } from '../apply.js';
|
|
2
|
-
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
|
+
import type { DeclaredOrg, HostingEntry, OrgId } from '../org.js';
|
|
3
3
|
import type { Plan, PlanStep } from '../plan.js';
|
|
4
4
|
import type { DeclaredPlatform } from '../platform.js';
|
|
5
5
|
import type { ReleaseStore } from '../release/state.js';
|
|
@@ -443,6 +443,18 @@ export declare class PlatformConfirmationRequiredError extends Error {
|
|
|
443
443
|
export interface FoundryApplyOptions {
|
|
444
444
|
/** Service names (`VersionChangeItem.service`), matched exactly, whose version change the operator confirmed. */
|
|
445
445
|
confirmUpgrade?: readonly string[];
|
|
446
|
+
/**
|
|
447
|
+
* A company's own confirmation for one of its apps, in place of the org's name. `apply` passes a
|
|
448
|
+
* destroying or migrating step only when {@link stepEntryKey} says it belongs to `entryKey` and
|
|
449
|
+
* `typed` equals `entryKey` exactly. Every other such step still needs the org's name, as does
|
|
450
|
+
* the whole plan when `typed` differs. It never passes a platform step.
|
|
451
|
+
*/
|
|
452
|
+
entryConfirmation?: {
|
|
453
|
+
entryKey: string;
|
|
454
|
+
typed: string;
|
|
455
|
+
/** The removed entry's gate keys, read by the caller from the company before the change. */
|
|
456
|
+
gates?: readonly string[];
|
|
457
|
+
};
|
|
446
458
|
}
|
|
447
459
|
export interface FoundryPlanApplyOptions {
|
|
448
460
|
store: DeclaredOrgStore;
|
|
@@ -947,3 +959,20 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
947
959
|
* than a second copy that could drift from it.
|
|
948
960
|
*/
|
|
949
961
|
export declare function planNeedsConfirmation(steps: readonly PlanStep[]): boolean;
|
|
962
|
+
/**
|
|
963
|
+
* The key of the hosting entry a step belongs to, or `null` for a step of the org or the platform.
|
|
964
|
+
* An entry's own steps are keyed `<orgId>:<entry key>` (its Coolify app, Worker, Worker app, and
|
|
965
|
+
* ai, edge and files keys); with `orgId` the prefix must be exactly that id. With `entries` (the
|
|
966
|
+
* declared ones) two more are known: the ZITADEL sign-in client named like an entry that declares
|
|
967
|
+
* `uses.auth`, and a gate named in an entry's `gates`. The org database, the org-wide files key,
|
|
968
|
+
* the customers organisation and the issuer belong to no entry. (`apply` lets an entry's
|
|
969
|
+
* confirmation pass the park of the org database once no declared entry uses a database.)
|
|
970
|
+
*/
|
|
971
|
+
export declare function stepEntryKey(step: {
|
|
972
|
+
action: Pick<StepAction, 'system' | 'kind' | 'key'>;
|
|
973
|
+
}, orgId?: string, entries?: readonly Pick<HostingEntry, 'key' | 'uses' | 'gates'>[]): string | null;
|
|
974
|
+
/** Whether `confirmation` passes every destroying or migrating step of `steps`: all belong to its entry, and it typed the key. */
|
|
975
|
+
export declare function entryConfirms(confirmation: FoundryApplyOptions['entryConfirmation'], steps: readonly {
|
|
976
|
+
step: PlanStep;
|
|
977
|
+
action: StepAction;
|
|
978
|
+
}[], orgId: string, entries: readonly HostingEntry[]): boolean;
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -5,6 +5,7 @@ import { ORG_SERVICE_SCHEMAS, heldEnvRef, ownsServices, validateHosting } from '
|
|
|
5
5
|
import { PLATFORM_SERVICE_KEYS } from '../platform.js';
|
|
6
6
|
import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
|
|
7
7
|
import { StaleLeaseError, } from '../store.js';
|
|
8
|
+
import { entryHasDatabase } from '../worker-app-config.js';
|
|
8
9
|
import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
|
|
9
10
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
10
11
|
import { NetworkMoveBackupMissingError, OrgPostgresMigrationBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForOrgPostgresMigration, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
|
|
@@ -577,7 +578,8 @@ export class FoundryPlanApply {
|
|
|
577
578
|
}
|
|
578
579
|
}
|
|
579
580
|
if (planNeedsConfirmation(plan.steps) &&
|
|
580
|
-
confirmedOrgName.trim() !== org.displayName.trim()
|
|
581
|
+
confirmedOrgName.trim() !== org.displayName.trim() &&
|
|
582
|
+
!entryConfirms(options.entryConfirmation, detail.plannedSteps, org.id, org.hosting.entries)) {
|
|
581
583
|
await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
|
|
582
584
|
throw new ConfirmationRequiredError(orgId);
|
|
583
585
|
}
|
|
@@ -1859,6 +1861,73 @@ export class FoundryPlanApply {
|
|
|
1859
1861
|
export function planNeedsConfirmation(steps) {
|
|
1860
1862
|
return steps.some((step) => step.destroys || step.migrates);
|
|
1861
1863
|
}
|
|
1864
|
+
/**
|
|
1865
|
+
* The key of the hosting entry a step belongs to, or `null` for a step of the org or the platform.
|
|
1866
|
+
* An entry's own steps are keyed `<orgId>:<entry key>` (its Coolify app, Worker, Worker app, and
|
|
1867
|
+
* ai, edge and files keys); with `orgId` the prefix must be exactly that id. With `entries` (the
|
|
1868
|
+
* declared ones) two more are known: the ZITADEL sign-in client named like an entry that declares
|
|
1869
|
+
* `uses.auth`, and a gate named in an entry's `gates`. The org database, the org-wide files key,
|
|
1870
|
+
* the customers organisation and the issuer belong to no entry. (`apply` lets an entry's
|
|
1871
|
+
* confirmation pass the park of the org database once no declared entry uses a database.)
|
|
1872
|
+
*/
|
|
1873
|
+
export function stepEntryKey(step, orgId, entries = []) {
|
|
1874
|
+
const { system, kind, key } = step.action;
|
|
1875
|
+
if (system === 'zitadel' && kind === 'app') {
|
|
1876
|
+
return entries.find((e) => e.key === key && e.uses?.auth)?.key ?? null;
|
|
1877
|
+
}
|
|
1878
|
+
const entryOwned = (system === 'coolify' && kind === 'app') ||
|
|
1879
|
+
(system === 'cloudflare' && (kind === 'worker' || kind === 'worker-app' || kind === 'gate')) ||
|
|
1880
|
+
(kind === 'key' && (system === 'ai' || system === 'edge' || system === 'files'));
|
|
1881
|
+
if (!entryOwned)
|
|
1882
|
+
return null;
|
|
1883
|
+
const colon = key.indexOf(':');
|
|
1884
|
+
if (colon < 1)
|
|
1885
|
+
return null;
|
|
1886
|
+
const prefix = key.slice(0, colon);
|
|
1887
|
+
if (orgId === undefined ? prefix === 'platform' : prefix !== orgId)
|
|
1888
|
+
return null;
|
|
1889
|
+
const rest = key.slice(colon + 1);
|
|
1890
|
+
if (rest === '' || rest.includes(':'))
|
|
1891
|
+
return null;
|
|
1892
|
+
if (kind === 'gate')
|
|
1893
|
+
return entries.find((e) => e.gates?.some((g) => g.key === rest))?.key ?? null;
|
|
1894
|
+
return rest;
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* The step that parks the org's one database. It belongs to the org, not to an entry, but the
|
|
1898
|
+
* entry that removes the last use of it may confirm it (see `entryConfirms`).
|
|
1899
|
+
*/
|
|
1900
|
+
function isOrgDatabasePark(step, orgId) {
|
|
1901
|
+
const { system, kind, key } = step.action;
|
|
1902
|
+
return system === 'coolify' && kind === 'database' && key === orgId && step.step.destroys;
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* The delete of one of the org's gates that no entry declares any more. The step names only the
|
|
1906
|
+
* gate, so the caller names the removed entry's gates (`entryConfirmation.gates`) and only those pass.
|
|
1907
|
+
*/
|
|
1908
|
+
function isUndeclaredGateDelete(step, orgId) {
|
|
1909
|
+
const { system, kind, key, desired } = step.action;
|
|
1910
|
+
return (system === 'cloudflare' &&
|
|
1911
|
+
kind === 'gate' &&
|
|
1912
|
+
key.startsWith(`${orgId}:`) &&
|
|
1913
|
+
desired?.mode === 'delete');
|
|
1914
|
+
}
|
|
1915
|
+
/** Whether `confirmation` passes every destroying or migrating step of `steps`: all belong to its entry, and it typed the key. */
|
|
1916
|
+
export function entryConfirms(confirmation, steps, orgId, entries) {
|
|
1917
|
+
if (!confirmation ||
|
|
1918
|
+
confirmation.entryKey === '' ||
|
|
1919
|
+
confirmation.typed !== confirmation.entryKey) {
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1922
|
+
return steps
|
|
1923
|
+
.filter(({ step }) => step.destroys || step.migrates)
|
|
1924
|
+
.every((planned) => stepEntryKey(planned, orgId, entries) === confirmation.entryKey ||
|
|
1925
|
+
(isOrgDatabasePark(planned, orgId) && !entries.some(entryHasDatabase)) ||
|
|
1926
|
+
(isUndeclaredGateDelete(planned, orgId) &&
|
|
1927
|
+
!planned.step.migrates &&
|
|
1928
|
+
(confirmation.gates ?? []).includes(planned.action.key.slice(orgId.length + 1)) &&
|
|
1929
|
+
!entries.some((e) => e.key === confirmation.entryKey)));
|
|
1930
|
+
}
|
|
1862
1931
|
/** `AppliedState.serviceIds`' two flat ZITADEL scope keys (`executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`) -- no `:`, unlike a resource key. */
|
|
1863
1932
|
const ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_project';
|
|
1864
1933
|
const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
@@ -3892,6 +3892,9 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
|
|
|
3892
3892
|
{ name: 'reporting-collect', path: '/api/reporting/collect' },
|
|
3893
3893
|
{ name: 'org-changes', path: '/api/orgs/*/changes' },
|
|
3894
3894
|
{ name: 'org-settings', path: '/api/orgs/*/settings' },
|
|
3895
|
+
{ name: 'org-apps', path: '/api/orgs/*/apps' },
|
|
3896
|
+
{ name: 'org-requests', path: '/api/orgs/*/requests' },
|
|
3897
|
+
{ name: 'github-releases', path: '/api/github/releases' },
|
|
3895
3898
|
];
|
|
3896
3899
|
const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
|
|
3897
3900
|
const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
|
|
@@ -280,12 +280,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
|
|
|
280
280
|
// Boule must hold too. In memory only, compared by `planBoule` and never
|
|
281
281
|
// written to a step, description or log.
|
|
282
282
|
const orgId = recorded[resource.key]?.slice('boule:'.length);
|
|
283
|
-
|
|
283
|
+
// The token sits on the org's main app, whatever its key; the reader does not know the
|
|
284
|
+
// declaration, so it tries the `app` entry first and then each other recorded app.
|
|
285
|
+
const appPrefix = `app:${orgId}:`;
|
|
286
|
+
const appUuids = Object.entries(recorded)
|
|
287
|
+
.filter(([, value]) => value.startsWith(appPrefix))
|
|
288
|
+
.sort(([, a], [, b]) => Number(b === `${appPrefix}${APP_ENTRY_KEY}`) - Number(a === `${appPrefix}${APP_ENTRY_KEY}`))
|
|
289
|
+
.map(([uuid]) => uuid);
|
|
284
290
|
// A recorded app that is not in the live listing is gone: no app token, and
|
|
285
291
|
// no read of its envs. A listed app whose read fails fails like any read.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
292
|
+
let appToken;
|
|
293
|
+
for (const appUuid of appUuids) {
|
|
294
|
+
if (!liveKeys.has(appUuid))
|
|
295
|
+
continue;
|
|
296
|
+
appToken = (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value;
|
|
297
|
+
if (appToken)
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
289
300
|
return {
|
|
290
301
|
...resource,
|
|
291
302
|
config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
|
package/dist/plan/worker-app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHostId, } from '../worker-app-config.js';
|
|
2
|
+
import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
|
|
3
3
|
import { entryAuthEnv } from './broker-entries.js';
|
|
4
4
|
import { workerAppHostname } from './hostnames.js';
|
|
5
5
|
import { appStepKey } from './platform-diff.js';
|
|
@@ -315,6 +315,8 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
315
315
|
description: `${at} is blocked, not planned: ${why}`,
|
|
316
316
|
key,
|
|
317
317
|
});
|
|
318
|
+
if (!workerAppHasBuild(app))
|
|
319
|
+
continue;
|
|
318
320
|
if (!input.available) {
|
|
319
321
|
block('the host supplies no WorkerAppPort');
|
|
320
322
|
continue;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DeclaredOrg, type OrgRefusal } from './org.js';
|
|
2
|
+
/**
|
|
3
|
+
* The first refusal of the whole-company validation a store write runs for
|
|
4
|
+
* hosting (every entry, its sign-in included) and customers, or `null`. Locked
|
|
5
|
+
* names are the org's own; the platform's service names are not known here.
|
|
6
|
+
*/
|
|
7
|
+
export declare function refuseOrg(org: DeclaredOrg): OrgRefusal | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { refuseCustomers, refuseHosting } from './org.js';
|
|
2
|
+
import { hostingNames } from './plan/locked-hostnames.js';
|
|
3
|
+
/**
|
|
4
|
+
* The first refusal of the whole-company validation a store write runs for
|
|
5
|
+
* hosting (every entry, its sign-in included) and customers, or `null`. Locked
|
|
6
|
+
* names are the org's own; the platform's service names are not known here.
|
|
7
|
+
*/
|
|
8
|
+
export function refuseOrg(org) {
|
|
9
|
+
return refuseHosting(org.hosting, hostingNames(org, [])) ?? refuseCustomers(org);
|
|
10
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { BuildJson } from './build-json.js';
|
|
2
|
+
import { type CustomersConfig, type DeclaredOrg, type HostingEntry, type MailMode, type OrgId } from './org.js';
|
|
2
3
|
import type { PlanDetail } from './plan/diff.js';
|
|
3
4
|
/**
|
|
4
5
|
* The whitelist of `DeclaredOrg` fields a company's own Boule can request a
|
|
@@ -15,7 +16,7 @@ import type { PlanDetail } from './plan/diff.js';
|
|
|
15
16
|
* lowering or leaving a limit unchanged -- raising either one needs
|
|
16
17
|
* approval too, until a billing field exists to say what the raise costs.
|
|
17
18
|
*/
|
|
18
|
-
export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
19
|
+
export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier' | 'app' | 'build';
|
|
19
20
|
export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
|
|
20
21
|
/** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
|
|
21
22
|
export interface CallsChange {
|
|
@@ -52,12 +53,34 @@ export interface MailTierChange {
|
|
|
52
53
|
field: 'mailTier';
|
|
53
54
|
mode: MailMode;
|
|
54
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Put or remove one hosting entry of the company's own app (`org.ts`'s `HostingEntry`).
|
|
58
|
+
* `before` and `after` are the entry as the requester saw it and as it should be (null when absent).
|
|
59
|
+
* `customers` is the switches after the change when the request changes them, else null.
|
|
60
|
+
* Archon validates the whole company; {@link classifyChange} does not look at the org for this kind.
|
|
61
|
+
*/
|
|
62
|
+
export interface AppChange {
|
|
63
|
+
field: 'app';
|
|
64
|
+
key: string;
|
|
65
|
+
op: 'put' | 'remove';
|
|
66
|
+
before: HostingEntry | null;
|
|
67
|
+
after: HostingEntry | null;
|
|
68
|
+
customers: CustomersConfig | null;
|
|
69
|
+
}
|
|
70
|
+
/** Deploy one release of the company's app from its `build.json` (`build-json.ts`). */
|
|
71
|
+
export interface BuildChange {
|
|
72
|
+
field: 'build';
|
|
73
|
+
key: string;
|
|
74
|
+
repo: string;
|
|
75
|
+
release: string;
|
|
76
|
+
build: BuildJson;
|
|
77
|
+
}
|
|
55
78
|
/**
|
|
56
79
|
* One company-requested change to its own declared org, keyed by
|
|
57
80
|
* {@link RequestableField}. `classifyChange` and the store both switch on
|
|
58
81
|
* `.field`.
|
|
59
82
|
*/
|
|
60
|
-
export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
|
|
83
|
+
export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange | AppChange | BuildChange;
|
|
61
84
|
/**
|
|
62
85
|
* `auto`: nothing further is required -- archon may carry the request
|
|
63
86
|
* straight into a plan and apply it. `approval`: a human must approve the
|
|
@@ -72,7 +95,21 @@ export type ChangeClassification = 'auto' | 'approval';
|
|
|
72
95
|
* onward. Foundry only stores and classifies (#178 "Why"); nothing in this
|
|
73
96
|
* package transitions a state past its initial value.
|
|
74
97
|
*/
|
|
75
|
-
export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied';
|
|
98
|
+
export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied' | 'applying' | 'failed';
|
|
99
|
+
/** Why an `app` or `build` request is `failed`. */
|
|
100
|
+
export type FailureCode = 'replaced' | 'refused' | 'busy' | 'timed_out' | 'apply_error';
|
|
101
|
+
/** Who asked: a person in Boule, or a release build. `requestedBy` keeps the raw principal id for audit. */
|
|
102
|
+
export type RequestedBy = {
|
|
103
|
+
via: 'boule';
|
|
104
|
+
person: {
|
|
105
|
+
id: string;
|
|
106
|
+
name: string;
|
|
107
|
+
};
|
|
108
|
+
} | {
|
|
109
|
+
via: 'build';
|
|
110
|
+
repo: string;
|
|
111
|
+
release: string;
|
|
112
|
+
};
|
|
76
113
|
/**
|
|
77
114
|
* One company-requested change to its own declared org, as stored --
|
|
78
115
|
* `migrations/0017_requested_changes.sql`'s `foundry_requested_changes`
|
|
@@ -85,6 +122,15 @@ export interface RequestedChange {
|
|
|
85
122
|
requestedBy: string;
|
|
86
123
|
createdAt: string;
|
|
87
124
|
state: RequestedChangeState;
|
|
125
|
+
/** Null for rows made before `app` and `build` requests. */
|
|
126
|
+
requester: RequestedBy | null;
|
|
127
|
+
idempotencyKey: string | null;
|
|
128
|
+
reason: {
|
|
129
|
+
code: FailureCode;
|
|
130
|
+
message: string;
|
|
131
|
+
replacedBy?: number;
|
|
132
|
+
} | null;
|
|
133
|
+
updatedAt: string;
|
|
88
134
|
}
|
|
89
135
|
/**
|
|
90
136
|
* Thrown by {@link classifyChange} (and by the requested-change store's
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateCustomers } from './org.js';
|
|
1
|
+
import { validateCustomers, } from './org.js';
|
|
2
2
|
export const REQUESTABLE_FIELDS = [
|
|
3
3
|
'calls',
|
|
4
4
|
'customers',
|
|
@@ -6,6 +6,8 @@ export const REQUESTABLE_FIELDS = [
|
|
|
6
6
|
'aiLimit',
|
|
7
7
|
'domain',
|
|
8
8
|
'mailTier',
|
|
9
|
+
'app',
|
|
10
|
+
'build',
|
|
9
11
|
];
|
|
10
12
|
/**
|
|
11
13
|
* Thrown by {@link classifyChange} (and by the requested-change store's
|
|
@@ -42,6 +44,9 @@ function applyRequestedChange(org, change) {
|
|
|
42
44
|
return { ...org, domain: change.domain };
|
|
43
45
|
case 'mailTier':
|
|
44
46
|
return { ...org, mail: { ...org.mail, mode: change.mode } };
|
|
47
|
+
case 'app':
|
|
48
|
+
case 'build':
|
|
49
|
+
return org;
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
/**
|
|
@@ -115,6 +120,9 @@ export function classifyChange(org, change, plan) {
|
|
|
115
120
|
if (!REQUESTABLE_FIELDS.includes(change.field)) {
|
|
116
121
|
throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
|
|
117
122
|
}
|
|
123
|
+
// Archon validates the whole company for these; nothing waits for approval.
|
|
124
|
+
if (change.field === 'app' || change.field === 'build')
|
|
125
|
+
return 'auto';
|
|
118
126
|
if (change.field === 'customers') {
|
|
119
127
|
validateCustomersChange(change);
|
|
120
128
|
// The org after the change must still pass the rule apply checks, for example open registration
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
import type { OrgRefusal, OrgRefusalCode } from '../org.js';
|
|
2
3
|
import { type ReleaseStore } from '../release/state.js';
|
|
3
4
|
import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
|
|
4
5
|
/**
|
|
@@ -38,7 +39,10 @@ export declare function uniqueViolationConstraint(error: unknown): string | unde
|
|
|
38
39
|
* is that throw.
|
|
39
40
|
*/
|
|
40
41
|
export declare class DeclaredOrgError extends Error {
|
|
41
|
-
|
|
42
|
+
/** Set when a hosting or customers validator refused the write: which rule, and where in the org. */
|
|
43
|
+
readonly code?: OrgRefusalCode;
|
|
44
|
+
readonly path?: string;
|
|
45
|
+
constructor(message: string, refusal?: Pick<OrgRefusal, 'code' | 'path'>);
|
|
42
46
|
}
|
|
43
47
|
/**
|
|
44
48
|
* `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and
|