@wtfalch/foundry 0.39.6 → 0.41.1
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/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/org.d.ts +36 -4
- package/dist/org.js +108 -3
- package/dist/plan/diff.js +75 -12
- package/dist/plan/executor.d.ts +14 -0
- package/dist/plan/executor.js +54 -1
- package/dist/plan/live.d.ts +7 -0
- package/dist/plan/own-sfu.d.ts +2 -0
- package/dist/plan/own-sfu.js +8 -3
- package/dist/plan/plan-apply.d.ts +13 -0
- package/dist/plan/plan-apply.js +60 -3
- package/dist/plan/platform-diff.d.ts +13 -0
- package/dist/plan/platform-diff.js +26 -1
- package/dist/plan/platform-org.d.ts +14 -0
- package/dist/plan/providers-readers.d.ts +1 -0
- package/dist/plan/providers-readers.js +10 -2
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, VersionPin, VersionPinPart, } from './org.js';
|
|
2
|
-
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
|
|
1
|
+
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, VersionPin, VersionPinPart, } from './org.js';
|
|
2
|
+
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
|
|
3
3
|
export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
|
|
4
4
|
export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
5
5
|
import { type PinnedVersions } from './bootstrap/versions.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
|
|
1
|
+
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateVersionPin, validateSite, } from './org.js';
|
|
2
2
|
export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
3
3
|
// archon#273: the pinned platform service versions the planner deploys, so a
|
|
4
4
|
// host can show declared against running. Readonly: it is the planner's own
|
package/dist/org.d.ts
CHANGED
|
@@ -323,6 +323,12 @@ export interface HostingEntry {
|
|
|
323
323
|
* restricted.
|
|
324
324
|
*/
|
|
325
325
|
capabilities?: 'restricted' | 'standard';
|
|
326
|
+
/**
|
|
327
|
+
* Named volumes that survive a redeploy, each mounted at an absolute path in the app container. The app step
|
|
328
|
+
* creates each one once, found by mount path, and restarts an app that was already running. An apply never
|
|
329
|
+
* removes one. Absent means none besides the log volume.
|
|
330
|
+
*/
|
|
331
|
+
volumes?: EntryVolume[];
|
|
326
332
|
/** Deploy on every push to `branch`. A host should declare `false`; foundry creates every other app with auto-deploy off. */
|
|
327
333
|
autoDeploy: boolean;
|
|
328
334
|
/**
|
|
@@ -350,6 +356,15 @@ export interface HostingEntry {
|
|
|
350
356
|
*/
|
|
351
357
|
worker?: WorkerConfig;
|
|
352
358
|
}
|
|
359
|
+
/** One named volume on a hosting entry's app. */
|
|
360
|
+
export interface EntryVolume {
|
|
361
|
+
/** A DNS label, unique per entry. The volume is named `<app name>-<name>`. */
|
|
362
|
+
name: string;
|
|
363
|
+
/** An absolute path in the container. Never `/app/logs`, which the log volume holds. */
|
|
364
|
+
mountPath: string;
|
|
365
|
+
}
|
|
366
|
+
/** The most volumes one entry may declare. */
|
|
367
|
+
export declare const MAX_ENTRY_VOLUMES = 8;
|
|
353
368
|
/** One Cloudflare Access gate over some of an entry's `hostnames`. */
|
|
354
369
|
export interface EntryGate {
|
|
355
370
|
/** A DNS label, unique in the org. It names the plan step and the edge gate. */
|
|
@@ -472,8 +487,8 @@ export declare const EDGE_ENTRY_ENV_NAMES: readonly ["EDGE_URL", "EDGE_API_KEY"]
|
|
|
472
487
|
* looks like a secret. `{ from }` names a value the plan already mints.
|
|
473
488
|
*/
|
|
474
489
|
export type EnvValue = string | {
|
|
475
|
-
from: Exclude<AppEnvSource, 'sharedSecret'>;
|
|
476
|
-
} | EnvSharedSecret;
|
|
490
|
+
from: Exclude<AppEnvSource, 'sharedSecret' | 'heldSecret'>;
|
|
491
|
+
} | EnvSharedSecret | EnvHeldSecret;
|
|
477
492
|
/**
|
|
478
493
|
* A secret foundry generates once and holds under an org-scoped ref. Every
|
|
479
494
|
* entry of the same org that names the same `name` gets the same value; an
|
|
@@ -484,6 +499,16 @@ export interface EnvSharedSecret {
|
|
|
484
499
|
from: 'sharedSecret';
|
|
485
500
|
name: string;
|
|
486
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* A secret the operator holds, written to the entry's env only. `name` is the
|
|
504
|
+
* env name it is declared under. The operator puts the value under
|
|
505
|
+
* {@link heldEnvRef}; foundry never generates it, and it never reaches a plan,
|
|
506
|
+
* a log or an error.
|
|
507
|
+
*/
|
|
508
|
+
export interface EnvHeldSecret {
|
|
509
|
+
from: 'heldSecret';
|
|
510
|
+
name: string;
|
|
511
|
+
}
|
|
487
512
|
/** The org's `app` entry: today's one app. */
|
|
488
513
|
export declare function appEntry(hosting: HostingConfig): HostingEntry | undefined;
|
|
489
514
|
/**
|
|
@@ -493,9 +518,9 @@ export declare function appEntry(hosting: HostingConfig): HostingEntry | undefin
|
|
|
493
518
|
* sends from. Each is read from `StepExecutionContext.recordedIds()` in
|
|
494
519
|
* `executor.ts`'s `pushAppEnv`, never minted again there.
|
|
495
520
|
*/
|
|
496
|
-
export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret';
|
|
521
|
+
export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret' | 'heldSecret';
|
|
497
522
|
/**
|
|
498
|
-
* `sharedSecret`
|
|
523
|
+
* `sharedSecret` ({@link EnvSharedSecret}) and `heldSecret` ({@link EnvHeldSecret}) are the secret sources. The
|
|
499
524
|
* platform's Lettermint key is not a source any more (2026-10-11): each
|
|
500
525
|
* company uses its own mail key.
|
|
501
526
|
*/
|
|
@@ -504,6 +529,13 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
|
504
529
|
export declare const SECRET_APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
505
530
|
/** The held-key ref of an org's shared secret. */
|
|
506
531
|
export declare const sharedSecretRef: (orgId: string, name: string) => string;
|
|
532
|
+
/**
|
|
533
|
+
* The held-key ref of an entry's `heldSecret` env: where the operator puts the
|
|
534
|
+
* value. Throws on an input the declaration would not accept. The org id is a
|
|
535
|
+
* canonical lowercase UUID: the host stores `:` as `-`, so an id that may hold `-`
|
|
536
|
+
* would let two refs share one stored entry.
|
|
537
|
+
*/
|
|
538
|
+
export declare const heldEnvRef: (orgId: string, entryKey: string, name: string) => string;
|
|
507
539
|
/**
|
|
508
540
|
* `null` when `hosting` is valid; otherwise the reason it is not (#71), same
|
|
509
541
|
* shape as `validateBuyerIdentity` below.
|
package/dist/org.js
CHANGED
|
@@ -8,6 +8,8 @@ export const ACCESS_RULE_KINDS = new Set(['bypass', 'service-token']);
|
|
|
8
8
|
export const DEFAULT_GATE_LIMIT = 3;
|
|
9
9
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
10
10
|
export const APP_ENTRY_KEY = 'app';
|
|
11
|
+
/** The most volumes one entry may declare. */
|
|
12
|
+
export const MAX_ENTRY_VOLUMES = 8;
|
|
11
13
|
/** What an entry's ai key may hold: the most the org's own key holds. */
|
|
12
14
|
export const AI_ENTRY_PERMISSIONS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
13
15
|
/** The env names `uses.ai` writes; `env` may not name them. */
|
|
@@ -41,7 +43,7 @@ export function appEntry(hosting) {
|
|
|
41
43
|
return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
44
|
-
* `sharedSecret`
|
|
46
|
+
* `sharedSecret` ({@link EnvSharedSecret}) and `heldSecret` ({@link EnvHeldSecret}) are the secret sources. The
|
|
45
47
|
* platform's Lettermint key is not a source any more (2026-10-11): each
|
|
46
48
|
* company uses its own mail key.
|
|
47
49
|
*/
|
|
@@ -50,11 +52,41 @@ export const APP_ENV_SOURCES = new Set([
|
|
|
50
52
|
'aiOrganisationId',
|
|
51
53
|
'mailDomain',
|
|
52
54
|
'sharedSecret',
|
|
55
|
+
'heldSecret',
|
|
53
56
|
]);
|
|
54
57
|
/** The sources whose value is a secret: resolved at apply time, never recorded. */
|
|
55
|
-
export const SECRET_APP_ENV_SOURCES = new Set([
|
|
58
|
+
export const SECRET_APP_ENV_SOURCES = new Set([
|
|
59
|
+
'sharedSecret',
|
|
60
|
+
'heldSecret',
|
|
61
|
+
]);
|
|
56
62
|
/** The held-key ref of an org's shared secret. */
|
|
57
63
|
export const sharedSecretRef = (orgId, name) => `foundry:shared-secret:${orgId}:${name}`;
|
|
64
|
+
const HELD_ENV_ORG_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
65
|
+
/** The host maps `:` to `-` in a stored id and stores at most 128 characters; the ref's fixed part is 52. */
|
|
66
|
+
const HELD_ENV_REF_MAX = 128;
|
|
67
|
+
const HELD_ENV_REF_FIXED = 52;
|
|
68
|
+
const HELD_ENV_ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
69
|
+
const HELD_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
70
|
+
/**
|
|
71
|
+
* The held-key ref of an entry's `heldSecret` env: where the operator puts the
|
|
72
|
+
* value. Throws on an input the declaration would not accept. The org id is a
|
|
73
|
+
* canonical lowercase UUID: the host stores `:` as `-`, so an id that may hold `-`
|
|
74
|
+
* would let two refs share one stored entry.
|
|
75
|
+
*/
|
|
76
|
+
export const heldEnvRef = (orgId, entryKey, name) => {
|
|
77
|
+
if (!HELD_ENV_ORG_ID.test(orgId))
|
|
78
|
+
throw new Error('heldEnvRef: orgId is not a valid org id');
|
|
79
|
+
if (!HELD_ENV_ENTRY_KEY.test(entryKey)) {
|
|
80
|
+
throw new Error('heldEnvRef: entryKey must be a DNS label (lowercase letters, digits, hyphens)');
|
|
81
|
+
}
|
|
82
|
+
if (!HELD_ENV_NAME.test(name)) {
|
|
83
|
+
throw new Error('heldEnvRef: name must be an env var name (letters, digits, underscores)');
|
|
84
|
+
}
|
|
85
|
+
if (HELD_ENV_REF_FIXED + entryKey.length + name.length > HELD_ENV_REF_MAX) {
|
|
86
|
+
throw new Error(`heldEnvRef: the ref would be longer than ${HELD_ENV_REF_MAX} characters`);
|
|
87
|
+
}
|
|
88
|
+
return `org:${orgId}:entry:${entryKey}:env:${name}`;
|
|
89
|
+
};
|
|
58
90
|
const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
|
|
59
91
|
const HOSTING_BUILD_PACKS = new Set([
|
|
60
92
|
'dockerfile',
|
|
@@ -297,11 +329,30 @@ function validateEntryHostnames(hosting, names) {
|
|
|
297
329
|
return null;
|
|
298
330
|
}
|
|
299
331
|
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
332
|
+
const VOLUME_MOUNT_PATH = /^(\/(?!\.\.?(\/|$))[A-Za-z0-9._-]+)+$/;
|
|
333
|
+
/** The log volume's mount path; `executor.ts`'s `LOG_DIR` is the same value. */
|
|
334
|
+
const LOG_DIR_PATH = '/app/logs';
|
|
335
|
+
/** Paths a volume would hide or nest under: the app's code, and the system roots a container needs. */
|
|
336
|
+
const VOLUME_FORBIDDEN_ROOTS = [
|
|
337
|
+
'/bin',
|
|
338
|
+
'/boot',
|
|
339
|
+
'/dev',
|
|
340
|
+
'/etc',
|
|
341
|
+
'/lib',
|
|
342
|
+
'/lib64',
|
|
343
|
+
'/proc',
|
|
344
|
+
'/run',
|
|
345
|
+
'/sbin',
|
|
346
|
+
'/sys',
|
|
347
|
+
'/usr',
|
|
348
|
+
'/var/run',
|
|
349
|
+
];
|
|
350
|
+
const VOLUME_MOUNT_PATH_MAX = 255;
|
|
300
351
|
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
301
352
|
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
302
353
|
const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
303
354
|
const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
|
|
304
|
-
const HELD_SECRETS = '
|
|
355
|
+
const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
|
|
305
356
|
/** The rules for one {@link HostingEntry}; `null` when it is valid. */
|
|
306
357
|
function validateHostingEntry(entry, enabled) {
|
|
307
358
|
const at = `hosting.entries '${entry.key}'`;
|
|
@@ -338,6 +389,9 @@ function validateHostingEntry(entry, enabled) {
|
|
|
338
389
|
if (entry.database && !enabled) {
|
|
339
390
|
return `${at} database requires hosting.enabled`;
|
|
340
391
|
}
|
|
392
|
+
const invalidVolumes = validateEntryVolumes(entry, at);
|
|
393
|
+
if (invalidVolumes)
|
|
394
|
+
return invalidVolumes;
|
|
341
395
|
for (const [name, value] of Object.entries(entry.env)) {
|
|
342
396
|
if (!name) {
|
|
343
397
|
return `${at} env needs a non-empty env var name`;
|
|
@@ -355,6 +409,14 @@ function validateHostingEntry(entry, enabled) {
|
|
|
355
409
|
if (value.from === 'sharedSecret' && !ENTRY_KEY.test(value.name ?? '')) {
|
|
356
410
|
return `${at} env '${name}' sharedSecret needs a name (lowercase letters, digits and hyphens)`;
|
|
357
411
|
}
|
|
412
|
+
if (value.from === 'heldSecret') {
|
|
413
|
+
if (value.name !== name) {
|
|
414
|
+
return `${at} env '${name}' heldSecret name must be '${name}', the env name it is declared under`;
|
|
415
|
+
}
|
|
416
|
+
if (!HELD_ENV_NAME.test(name)) {
|
|
417
|
+
return `${at} env '${name}' heldSecret needs an env name of letters, digits and underscores`;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
358
420
|
}
|
|
359
421
|
else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
|
|
360
422
|
return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
|
|
@@ -362,6 +424,49 @@ function validateHostingEntry(entry, enabled) {
|
|
|
362
424
|
}
|
|
363
425
|
return null;
|
|
364
426
|
}
|
|
427
|
+
/** `volumes`: a bounded list of named volumes at distinct absolute paths, none on the log volume's path or name,
|
|
428
|
+
* on `/app` or under a system path. */
|
|
429
|
+
function validateEntryVolumes(entry, at) {
|
|
430
|
+
const volumes = entry.volumes;
|
|
431
|
+
if (volumes === undefined)
|
|
432
|
+
return null;
|
|
433
|
+
if (!Array.isArray(volumes))
|
|
434
|
+
return `${at} volumes must be a list`;
|
|
435
|
+
if (volumes.length > MAX_ENTRY_VOLUMES) {
|
|
436
|
+
return `${at} volumes holds at most ${MAX_ENTRY_VOLUMES} volumes`;
|
|
437
|
+
}
|
|
438
|
+
const names = new Set();
|
|
439
|
+
const paths = new Set();
|
|
440
|
+
for (const volume of volumes) {
|
|
441
|
+
const { name, mountPath } = volume ?? {};
|
|
442
|
+
if (typeof name !== 'string' || !ENTRY_KEY.test(name)) {
|
|
443
|
+
return `${at} volumes needs a name that is a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`;
|
|
444
|
+
}
|
|
445
|
+
if (name === 'logs') {
|
|
446
|
+
return `${at} volumes name 'logs' is the log volume's, which every app already has`;
|
|
447
|
+
}
|
|
448
|
+
if (names.has(name))
|
|
449
|
+
return `${at} volumes name '${name}' is not unique`;
|
|
450
|
+
names.add(name);
|
|
451
|
+
if (typeof mountPath !== 'string' || !VOLUME_MOUNT_PATH.test(mountPath)) {
|
|
452
|
+
return `${at} volumes '${name}' mountPath must be an absolute path of letters, digits, '.', '_', '-' and '/', with no '..', no empty part and no trailing '/'`;
|
|
453
|
+
}
|
|
454
|
+
if (mountPath.length > VOLUME_MOUNT_PATH_MAX) {
|
|
455
|
+
return `${at} volumes '${name}' mountPath holds at most ${VOLUME_MOUNT_PATH_MAX} characters`;
|
|
456
|
+
}
|
|
457
|
+
if (mountPath === LOG_DIR_PATH || mountPath.startsWith(`${LOG_DIR_PATH}/`)) {
|
|
458
|
+
return `${at} volumes '${name}' mountPath ${LOG_DIR_PATH} is the log volume's, which every app already has`;
|
|
459
|
+
}
|
|
460
|
+
if (mountPath === '/app' ||
|
|
461
|
+
VOLUME_FORBIDDEN_ROOTS.some((root) => mountPath === root || mountPath.startsWith(`${root}/`))) {
|
|
462
|
+
return `${at} volumes '${name}' mountPath must not be /app or a system path (${VOLUME_FORBIDDEN_ROOTS.join(', ')})`;
|
|
463
|
+
}
|
|
464
|
+
if (paths.has(mountPath))
|
|
465
|
+
return `${at} volumes mountPath '${mountPath}' is not unique`;
|
|
466
|
+
paths.add(mountPath);
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
365
470
|
/**
|
|
366
471
|
* `uses.auth`: its two switches are known values, sign-up is never open
|
|
367
472
|
* into the staff organisation, the entry has an exact
|
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, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
|
|
2
|
+
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
|
|
3
3
|
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';
|
|
4
4
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
@@ -19,7 +19,7 @@ import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './netwo
|
|
|
19
19
|
import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, OWN_SIGNIN_DB_MOVE_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, planSigninDbMove, } from './own-diff.js';
|
|
20
20
|
import { ownDnsHostnames } from './own-dns.js';
|
|
21
21
|
import { ownHostnames } from './own-hostnames.js';
|
|
22
|
-
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
22
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
23
23
|
import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
|
|
24
24
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
25
25
|
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
|
|
@@ -2674,6 +2674,13 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
2674
2674
|
* from it, which it deletes instead of parking: nothing can redeploy it.
|
|
2675
2675
|
*/
|
|
2676
2676
|
const RETIRED_DEFAULT_APP_REPO = 'wtfalch/foundry';
|
|
2677
|
+
/** What the provision step says about the entry's declared volumes; empty when it declares none. */
|
|
2678
|
+
function volumesNote(entry) {
|
|
2679
|
+
const volumes = entry?.volumes ?? [];
|
|
2680
|
+
return volumes.length === 0
|
|
2681
|
+
? ''
|
|
2682
|
+
: ` with volumes ${volumes.map((v) => `${v.name} at ${v.mountPath}`).join(', ')}`;
|
|
2683
|
+
}
|
|
2677
2684
|
/**
|
|
2678
2685
|
* The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
|
|
2679
2686
|
* per-org singleton resource modeled on {@link planSite}: park, not delete,
|
|
@@ -2713,11 +2720,9 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
2713
2720
|
// other entry is named in them.
|
|
2714
2721
|
const label = entryKey === APP_ENTRY_KEY ? 'app' : `${entryKey} app`;
|
|
2715
2722
|
const blockedKey = (rest) => entryKey === APP_ENTRY_KEY ? `coolify:app:${rest}` : `coolify:app:${entryKey}:${rest}`;
|
|
2716
|
-
const { marker: wantedEnv, unresolved, unresolvedSecrets, } = entry
|
|
2717
|
-
? resolveAppEnv(org, entry, recordedIds,
|
|
2718
|
-
|
|
2719
|
-
})
|
|
2720
|
-
: { marker: {}, unresolved: [], unresolvedSecrets: [] };
|
|
2723
|
+
const { marker: wantedEnv, unresolved, unresolvedSecrets, heldSecrets, unresolvedHeld, } = entry
|
|
2724
|
+
? resolveAppEnv(org, entry, recordedIds, appSecretAvailability(org, entry, readiness))
|
|
2725
|
+
: { marker: {}, unresolved: [], unresolvedSecrets: [], heldSecrets: [], unresolvedHeld: [] };
|
|
2721
2726
|
const stepKey = appStepKey(org, entryKey);
|
|
2722
2727
|
const blocked = entry
|
|
2723
2728
|
? unresolved
|
|
@@ -2732,6 +2737,24 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
2732
2737
|
unresolvedSecrets.map(([name, source]) => ({
|
|
2733
2738
|
description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${`its source ${source} needs a held-key store that can put, and the host has none`}`,
|
|
2734
2739
|
key: blockedKey(`env:${name}`),
|
|
2740
|
+
})))
|
|
2741
|
+
.concat(
|
|
2742
|
+
// The ref to put the value under, never a value.
|
|
2743
|
+
unresolvedHeld.map((name) => ({
|
|
2744
|
+
description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${readiness.heldEnv?.invalid?.includes(`${entryKey}/${name}`)
|
|
2745
|
+
? `held secret ${name} of entry '${entryKey}' fits no held-key ref (the org id must be a lowercase UUID and the ref at most 128 characters)`
|
|
2746
|
+
: readiness.heldEnv?.store
|
|
2747
|
+
? `held secret ${name} of entry '${entryKey}' has no value held (ref ${heldEnvRef(org.id, entryKey, name)})`
|
|
2748
|
+
: `held secret ${name} of entry '${entryKey}' needs a held-key store, and the host has none`}`,
|
|
2749
|
+
key: blockedKey(`env:${name}`),
|
|
2750
|
+
})))
|
|
2751
|
+
.concat(
|
|
2752
|
+
// Held, but the live env could not be read: no step, so the plan can still end empty.
|
|
2753
|
+
heldSecrets
|
|
2754
|
+
.filter((name) => readiness.heldEnv?.unreadable?.includes(heldEnvRef(org.id, entryKey, name)))
|
|
2755
|
+
.map((name) => ({
|
|
2756
|
+
description: `${org.slug}'s ${label} env ${name} is blocked, not planned: the live value of held secret ${name} of entry '${entryKey}' could not be read`,
|
|
2757
|
+
key: blockedKey(`env:${name}`),
|
|
2735
2758
|
})))
|
|
2736
2759
|
: [];
|
|
2737
2760
|
// #491: the `app` entry's own Lettermint work (`executor.ts`'s
|
|
@@ -2781,7 +2804,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
2781
2804
|
});
|
|
2782
2805
|
}
|
|
2783
2806
|
const refused = noRepo || composeBuilt;
|
|
2784
|
-
const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
|
|
2807
|
+
const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}${volumesNote(entry)}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
|
|
2785
2808
|
// #447: only the live app's own `git_repository` decides, never the
|
|
2786
2809
|
// declared entry (a `repo: null` entry can sit over a bespoke app, which is
|
|
2787
2810
|
// parked as before). Only the `app` key ever held the default app, and an
|
|
@@ -2837,6 +2860,28 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
2837
2860
|
blocked,
|
|
2838
2861
|
};
|
|
2839
2862
|
}
|
|
2863
|
+
// A declared volume the app does not hold yet is created by the same step. Never the reverse: a
|
|
2864
|
+
// volume the entry no longer declares is left alone. A reader that did not list volumes
|
|
2865
|
+
// (`volumeMounts` absent) reads as compliant, like `hasLogVolume`.
|
|
2866
|
+
const mounted = live.config.volumeMounts;
|
|
2867
|
+
const missingVolumes = Array.isArray(mounted)
|
|
2868
|
+
? (entry.volumes ?? []).filter((v) => !mounted.includes(v.mountPath))
|
|
2869
|
+
: [];
|
|
2870
|
+
if (missingVolumes.length > 0) {
|
|
2871
|
+
return {
|
|
2872
|
+
step: {
|
|
2873
|
+
action,
|
|
2874
|
+
step: {
|
|
2875
|
+
description: `create ${org.slug}'s ${label} volumes ${missingVolumes
|
|
2876
|
+
.map((v) => `${v.name} at ${v.mountPath}`)
|
|
2877
|
+
.join(', ')} and restart it`,
|
|
2878
|
+
destroys: false,
|
|
2879
|
+
migrates: false,
|
|
2880
|
+
},
|
|
2881
|
+
},
|
|
2882
|
+
blocked,
|
|
2883
|
+
};
|
|
2884
|
+
}
|
|
2840
2885
|
// #543: a human turning the health check off in the Coolify UI (or an app
|
|
2841
2886
|
// applied before this patch existed) is noticed the same way `hasLogVolume`
|
|
2842
2887
|
// just above is -- `coolifyApp` already sends the quartet on every run, so
|
|
@@ -2911,6 +2956,23 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
2911
2956
|
blocked,
|
|
2912
2957
|
};
|
|
2913
2958
|
}
|
|
2959
|
+
// A held secret is planned only while its live production value differs from the held one
|
|
2960
|
+
// (the plan read that in memory), so a converged app plans nothing. `pushAppEnv` restarts the
|
|
2961
|
+
// app only when the live value differs.
|
|
2962
|
+
const staleHeld = heldSecrets.filter((name) => readiness.heldEnv?.differs?.includes(heldEnvRef(org.id, entryKey, name)));
|
|
2963
|
+
if (staleHeld.length > 0) {
|
|
2964
|
+
return {
|
|
2965
|
+
step: {
|
|
2966
|
+
action,
|
|
2967
|
+
step: {
|
|
2968
|
+
description: `write ${org.slug}'s ${label} held secret env ${staleHeld.join(', ')} (values read at apply)`,
|
|
2969
|
+
destroys: false,
|
|
2970
|
+
migrates: false,
|
|
2971
|
+
},
|
|
2972
|
+
},
|
|
2973
|
+
blocked,
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2914
2976
|
// F5 (rule 3): who signs in, which client and which issuer reach an app
|
|
2915
2977
|
// that is already live. `pushAppEnv` records what it pushed.
|
|
2916
2978
|
if (entry.uses?.auth &&
|
|
@@ -3018,13 +3080,14 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
3018
3080
|
// The same availability the plan's marker used: the executor records its
|
|
3019
3081
|
// marker from this, never from its own port, so the two cannot disagree.
|
|
3020
3082
|
const usesSecrets = org.hosting.entries.some((e) => e.key === key &&
|
|
3021
|
-
Object.values(e.env).some((v) => typeof v !== 'string' && v.from === 'sharedSecret'));
|
|
3083
|
+
Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'heldSecret')));
|
|
3022
3084
|
if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
|
|
3085
|
+
const entry = org.hosting.entries.find((e) => e.key === key);
|
|
3023
3086
|
app.step.action.desired = {
|
|
3024
3087
|
...app.step.action.desired,
|
|
3025
|
-
secretSources:
|
|
3026
|
-
|
|
3027
|
-
|
|
3088
|
+
secretSources: entry
|
|
3089
|
+
? appSecretAvailability(org, entry, readiness)
|
|
3090
|
+
: { sharedSecret: readiness.canHoldSecrets === true },
|
|
3028
3091
|
};
|
|
3029
3092
|
}
|
|
3030
3093
|
if (app.step && app.step.action.desired.enabled !== false) {
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -2138,6 +2138,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2138
2138
|
* one).
|
|
2139
2139
|
*/
|
|
2140
2140
|
private ensureLogVolume;
|
|
2141
|
+
/**
|
|
2142
|
+
* The entry's declared `volumes`: each is created once, found by mount path
|
|
2143
|
+
* like {@link ensureLogVolume}'s, and never removed or changed here. One
|
|
2144
|
+
* restart after the last creation, only for an app that was already running.
|
|
2145
|
+
* A failure part way leaves the volumes already created in place; the next
|
|
2146
|
+
* apply finds them by path and creates the rest.
|
|
2147
|
+
*/
|
|
2148
|
+
private ensureEntryVolumes;
|
|
2141
2149
|
/**
|
|
2142
2150
|
* F6: a nightly dump of one of the company's databases to the platform's
|
|
2143
2151
|
* backup bucket. The schedule is found before it is created
|
|
@@ -2212,6 +2220,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2212
2220
|
* generates from `domains`. The router names use the app's own name, which is unique per company.
|
|
2213
2221
|
*/
|
|
2214
2222
|
private routedLabels;
|
|
2223
|
+
/**
|
|
2224
|
+
* The value the operator holds for an entry's `heldSecret` env, registered for redaction before
|
|
2225
|
+
* anything else can see it. The plan found it held; a value gone since stops the step, and the
|
|
2226
|
+
* error names the ref, never a value.
|
|
2227
|
+
*/
|
|
2228
|
+
private readHeldEnv;
|
|
2215
2229
|
/**
|
|
2216
2230
|
* The org's shared secret `name`: read from the held keys, else generated
|
|
2217
2231
|
* and held first, so a crash after the `put` and before the env write
|
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, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
7
|
+
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, heldEnvRef, 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, 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, entryClientIdScopeKey, } from './agora.js';
|
|
10
10
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
@@ -2515,6 +2515,7 @@ export class FoundryStepExecutor {
|
|
|
2515
2515
|
// call -- see its own doc comment for why that decides whether to
|
|
2516
2516
|
// restart it.
|
|
2517
2517
|
await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
|
|
2518
|
+
await this.ensureEntryVolumes(appId, entry, Boolean(existing), appNameFor(org, entry));
|
|
2518
2519
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
2519
2520
|
// unlike the database push above: every app has a hostname, and an
|
|
2520
2521
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
@@ -2624,6 +2625,31 @@ export class FoundryStepExecutor {
|
|
|
2624
2625
|
await this.ports.coolify.restart(appId);
|
|
2625
2626
|
}
|
|
2626
2627
|
}
|
|
2628
|
+
/**
|
|
2629
|
+
* The entry's declared `volumes`: each is created once, found by mount path
|
|
2630
|
+
* like {@link ensureLogVolume}'s, and never removed or changed here. One
|
|
2631
|
+
* restart after the last creation, only for an app that was already running.
|
|
2632
|
+
* A failure part way leaves the volumes already created in place; the next
|
|
2633
|
+
* apply finds them by path and creates the rest.
|
|
2634
|
+
*/
|
|
2635
|
+
async ensureEntryVolumes(appId, entry, wasAlreadyRunning, appName) {
|
|
2636
|
+
if (!entry.volumes?.length)
|
|
2637
|
+
return;
|
|
2638
|
+
const storages = await this.ports.coolify.listStorages(appId);
|
|
2639
|
+
const mounted = new Set((storages.persistent_storages ?? []).map((s) => s.mount_path));
|
|
2640
|
+
let created = false;
|
|
2641
|
+
for (const volume of entry.volumes) {
|
|
2642
|
+
if (mounted.has(volume.mountPath))
|
|
2643
|
+
continue;
|
|
2644
|
+
await this.ports.coolify.createPersistentStorage(appId, {
|
|
2645
|
+
name: `${appName}-${volume.name}`,
|
|
2646
|
+
mount_path: volume.mountPath,
|
|
2647
|
+
});
|
|
2648
|
+
created = true;
|
|
2649
|
+
}
|
|
2650
|
+
if (created && wasAlreadyRunning)
|
|
2651
|
+
await this.ports.coolify.restart(appId);
|
|
2652
|
+
}
|
|
2627
2653
|
/**
|
|
2628
2654
|
* F6: a nightly dump of one of the company's databases to the platform's
|
|
2629
2655
|
* backup bucket. The schedule is found before it is created
|
|
@@ -2866,6 +2892,17 @@ export class FoundryStepExecutor {
|
|
|
2866
2892
|
value: await this.ensureSharedSecret(org, source.name, recorded),
|
|
2867
2893
|
});
|
|
2868
2894
|
}
|
|
2895
|
+
const heldEnvs = [];
|
|
2896
|
+
for (const name of resolved.heldSecrets) {
|
|
2897
|
+
heldEnvs.push({ key: name, value: await this.readHeldEnv(org, entry.key, name) });
|
|
2898
|
+
}
|
|
2899
|
+
envs.push(...heldEnvs);
|
|
2900
|
+
// A held value has no version the plan can read, so the live env decides whether it changed.
|
|
2901
|
+
let heldChanged = false;
|
|
2902
|
+
if (heldEnvs.length > 0) {
|
|
2903
|
+
const live = await this.ports.coolify.listEnvs(appId);
|
|
2904
|
+
heldChanged = heldEnvs.some(({ key, value }) => !live.some((e) => e.key === key && !e.is_preview && e.value === value));
|
|
2905
|
+
}
|
|
2869
2906
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
2870
2907
|
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
2871
2908
|
// replans this step when the declared map changes (rule 3). Ids and a
|
|
@@ -2876,6 +2913,7 @@ export class FoundryStepExecutor {
|
|
|
2876
2913
|
const authApplied = JSON.stringify(authEnv);
|
|
2877
2914
|
return {
|
|
2878
2915
|
changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
|
|
2916
|
+
heldChanged ||
|
|
2879
2917
|
(Boolean(entry.uses?.auth) &&
|
|
2880
2918
|
authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')),
|
|
2881
2919
|
applied,
|
|
@@ -2916,6 +2954,20 @@ export class FoundryStepExecutor {
|
|
|
2916
2954
|
}
|
|
2917
2955
|
return { custom_labels: Buffer.from(labels).toString('base64') };
|
|
2918
2956
|
}
|
|
2957
|
+
/**
|
|
2958
|
+
* The value the operator holds for an entry's `heldSecret` env, registered for redaction before
|
|
2959
|
+
* anything else can see it. The plan found it held; a value gone since stops the step, and the
|
|
2960
|
+
* error names the ref, never a value.
|
|
2961
|
+
*/
|
|
2962
|
+
async readHeldEnv(org, entryKey, name) {
|
|
2963
|
+
const ref = heldEnvRef(org.id, entryKey, name);
|
|
2964
|
+
const value = await this.ports.heldKeys?.resolve(ref);
|
|
2965
|
+
if (!value) {
|
|
2966
|
+
throw new Error(`coolify:app for ${org.slug}: held secret ${name} of entry '${entryKey}' is no longer held (ref ${ref})`);
|
|
2967
|
+
}
|
|
2968
|
+
this.handledSecrets.add(value);
|
|
2969
|
+
return value;
|
|
2970
|
+
}
|
|
2919
2971
|
/**
|
|
2920
2972
|
* The org's shared secret `name`: read from the held keys, else generated
|
|
2921
2973
|
* and held first, so a crash after the `put` and before the env write
|
|
@@ -5923,6 +5975,7 @@ export class FoundryStepExecutor {
|
|
|
5923
5975
|
apex: ownHostnames(org).zone,
|
|
5924
5976
|
version: action.desired.version,
|
|
5925
5977
|
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
5978
|
+
...(context.serverIpv4 ? { serverIpv4: context.serverIpv4 } : {}),
|
|
5926
5979
|
});
|
|
5927
5980
|
}
|
|
5928
5981
|
async jobsRegistration(action, org, context) {
|
package/dist/plan/live.d.ts
CHANGED
|
@@ -62,6 +62,13 @@ export interface ZitadelReader {
|
|
|
62
62
|
}
|
|
63
63
|
export interface CoolifyReader {
|
|
64
64
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the production env `name` of the org's entry app, as it is live now, equals `value`.
|
|
67
|
+
* Compared in memory and never returned or kept. False when the app is not recorded or has no
|
|
68
|
+
* such env; throws when the read fails. Optional: without it a held secret's live value cannot
|
|
69
|
+
* be read, and the plan blocks that env.
|
|
70
|
+
*/
|
|
71
|
+
liveEnvEquals?(orgId: OrgId, appKey: string, name: string, value: string): Promise<boolean>;
|
|
65
72
|
/**
|
|
66
73
|
* Reads one Coolify project by its uuid, for an org that records none yet
|
|
67
74
|
* (adopt, archon#271). Optional: without it nothing can be adopted.
|
package/dist/plan/own-sfu.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface SfuAppInput {
|
|
|
45
45
|
/** The pinned LiveKit release tag. */
|
|
46
46
|
version: string;
|
|
47
47
|
project: ProjectSource;
|
|
48
|
+
/** The platform's declared public IPv4: LiveKit's `node_ip`. Absent: the Coolify server's ip, when it is an IPv4. */
|
|
49
|
+
serverIpv4?: string;
|
|
48
50
|
}
|
|
49
51
|
/** The compose `ports` lines for a slot: the UDP range and the TCP fallback port, one to one. */
|
|
50
52
|
export declare function sfuComposePorts(slot: number): string[];
|
package/dist/plan/own-sfu.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isIPv4 } from 'node:net';
|
|
1
2
|
import { DIGESTS, VERSIONS } from '../bootstrap/versions.js';
|
|
2
3
|
import { mintLivekitCredentials } from '../calls-sfu-keys.js';
|
|
3
4
|
import { LIVEKIT_SIGNALLING_PORT, sfuSlotPorts } from '../calls-sfu.js';
|
|
@@ -132,8 +133,12 @@ export async function provisionSfuApp(scope, deps, target, input) {
|
|
|
132
133
|
const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
|
|
133
134
|
if (!server)
|
|
134
135
|
throw new Error(`${label}: no usable Coolify server is registered`);
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
// Coolify reports its localhost server as `host.docker.internal`, which LiveKit refuses as `node_ip`
|
|
137
|
+
// and then restarts forever; the declared address wins, the same rule as the platform's mail server.
|
|
138
|
+
const nodeIp = input.serverIpv4 ?? (server.ip && isIPv4(server.ip) ? server.ip : undefined);
|
|
139
|
+
if (!nodeIp) {
|
|
140
|
+
throw new Error(`${label}: no public IPv4 to give LiveKit; declare the server's public IPv4 as serverIpv4 in the platform config`);
|
|
141
|
+
}
|
|
137
142
|
const compose = sfuCompose({
|
|
138
143
|
serviceName: target.internal.sfu,
|
|
139
144
|
slot: input.slot,
|
|
@@ -213,7 +218,7 @@ export async function provisionSfuApp(scope, deps, target, input) {
|
|
|
213
218
|
keysJustSet = true;
|
|
214
219
|
}
|
|
215
220
|
}
|
|
216
|
-
const config = sfuConfig({ slot: input.slot, nodeIp
|
|
221
|
+
const config = sfuConfig({ slot: input.slot, nodeIp, keys });
|
|
217
222
|
deps.onSecret?.(config);
|
|
218
223
|
const envChanged = liveConfig !== config;
|
|
219
224
|
const pending = Boolean(scope.recordedIds()[OWN_SFU_DEPLOY_PENDING_SCOPE_KEY]) || changed || envChanged;
|
|
@@ -130,6 +130,12 @@ export interface StepExecutionContext {
|
|
|
130
130
|
* platform's Docker network, not on `co-<slug>`, so Archon reaches its database. Absent: false.
|
|
131
131
|
*/
|
|
132
132
|
readonly ownerCompany?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* The platform's declared public IPv4 (`DeclaredPlatform.serverIpv4`). A company's media server
|
|
135
|
+
* gives it to LiveKit as `node_ip`: Coolify reports its localhost server as `host.docker.internal`,
|
|
136
|
+
* which LiveKit refuses. Absent: none declared.
|
|
137
|
+
*/
|
|
138
|
+
readonly serverIpv4?: string;
|
|
133
139
|
/**
|
|
134
140
|
* Every id foundry has recorded for this org so far — both `system:kind:key`
|
|
135
141
|
* resource ids (`runSteps` records these itself from `execute`'s own return
|
|
@@ -753,6 +759,13 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
753
759
|
* org step that needs one is blocked.
|
|
754
760
|
*/
|
|
755
761
|
private orgPlatformReadiness;
|
|
762
|
+
/**
|
|
763
|
+
* The org's `heldSecret` envs: the refs that hold a value now, which of those differ from the live
|
|
764
|
+
* production env, which could not be read, and which fit no ref. A resolver that throws counts as
|
|
765
|
+
* not held. The held value is compared in memory by the reader and dropped; a failed read keeps
|
|
766
|
+
* only the ref.
|
|
767
|
+
*/
|
|
768
|
+
private heldEnvReadiness;
|
|
756
769
|
/** An own org's Lettermint key is held. A resolver that throws counts as not held. */
|
|
757
770
|
private ownMailKeyHeld;
|
|
758
771
|
/** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
3
3
|
import { sfuSlotPorts } from '../calls-sfu.js';
|
|
4
|
-
import { ownsServices, validateHosting } from '../org.js';
|
|
4
|
+
import { heldEnvRef, ownsServices, validateHosting } from '../org.js';
|
|
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';
|
|
@@ -908,10 +908,13 @@ export class FoundryPlanApply {
|
|
|
908
908
|
const checkpoints = [];
|
|
909
909
|
// Backed by the very `serviceIds` map this method persists once, at the
|
|
910
910
|
// end — see `StepExecutionContext`'s doc comment for why.
|
|
911
|
-
const
|
|
911
|
+
const declaredPlatform = await this.platformDeps?.store.get();
|
|
912
|
+
const ownerCompany = declaredPlatform?.ownerOrgId === orgId;
|
|
913
|
+
const serverIpv4 = declaredPlatform?.serverIpv4 ?? undefined;
|
|
912
914
|
const context = {
|
|
913
915
|
org,
|
|
914
916
|
ownerCompany,
|
|
917
|
+
...(serverIpv4 ? { serverIpv4 } : {}),
|
|
915
918
|
recordedIds: () => ({ ...serviceIds }),
|
|
916
919
|
now: () => this.now().getTime(),
|
|
917
920
|
recordScopeId: (key, id) => {
|
|
@@ -1163,7 +1166,10 @@ export class FoundryPlanApply {
|
|
|
1163
1166
|
* org step that needs one is blocked.
|
|
1164
1167
|
*/
|
|
1165
1168
|
async orgPlatformReadiness(org, orgRecordedIds) {
|
|
1166
|
-
const base =
|
|
1169
|
+
const base = {
|
|
1170
|
+
...(await this.sharedPlatformReadiness()),
|
|
1171
|
+
heldEnv: await this.heldEnvReadiness(org),
|
|
1172
|
+
};
|
|
1167
1173
|
if (!ownsServices(org))
|
|
1168
1174
|
return base;
|
|
1169
1175
|
const held = this.platformDeps?.heldKeys;
|
|
@@ -1173,6 +1179,57 @@ export class FoundryPlanApply {
|
|
|
1173
1179
|
? { ...readiness, serviceGitRefs: serviceGitRefsOf(platform, org.versionPin) }
|
|
1174
1180
|
: readiness;
|
|
1175
1181
|
}
|
|
1182
|
+
/**
|
|
1183
|
+
* The org's `heldSecret` envs: the refs that hold a value now, which of those differ from the live
|
|
1184
|
+
* production env, which could not be read, and which fit no ref. A resolver that throws counts as
|
|
1185
|
+
* not held. The held value is compared in memory by the reader and dropped; a failed read keeps
|
|
1186
|
+
* only the ref.
|
|
1187
|
+
*/
|
|
1188
|
+
async heldEnvReadiness(org) {
|
|
1189
|
+
const heldKeys = this.platformDeps?.heldKeys;
|
|
1190
|
+
const refs = [];
|
|
1191
|
+
const differs = [];
|
|
1192
|
+
const unreadable = [];
|
|
1193
|
+
const invalid = [];
|
|
1194
|
+
for (const entry of org.hosting.entries) {
|
|
1195
|
+
for (const [name, value] of Object.entries(entry.env)) {
|
|
1196
|
+
if (typeof value === 'string' || value.from !== 'heldSecret')
|
|
1197
|
+
continue;
|
|
1198
|
+
let ref;
|
|
1199
|
+
try {
|
|
1200
|
+
ref = heldEnvRef(org.id, entry.key, name);
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
// Fits no ref: the plan blocks it by entry and name; the error text is fixed anyway.
|
|
1204
|
+
invalid.push(`${entry.key}/${name}`);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
let held;
|
|
1208
|
+
try {
|
|
1209
|
+
held = await heldKeys?.resolve(ref);
|
|
1210
|
+
}
|
|
1211
|
+
catch {
|
|
1212
|
+
// Not held: the plan reports the ref, and the error is dropped.
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (!held)
|
|
1216
|
+
continue;
|
|
1217
|
+
refs.push(ref);
|
|
1218
|
+
try {
|
|
1219
|
+
const equal = await this.readers.coolify.liveEnvEquals?.(org.id, appStepKey(org, entry.key), name, held);
|
|
1220
|
+
if (equal === undefined)
|
|
1221
|
+
unreadable.push(ref);
|
|
1222
|
+
else if (!equal)
|
|
1223
|
+
differs.push(ref);
|
|
1224
|
+
}
|
|
1225
|
+
catch {
|
|
1226
|
+
// Blocked, not planned: the plan names the env and the entry; the error is dropped.
|
|
1227
|
+
unreadable.push(ref);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return { store: Boolean(heldKeys), refs, differs, unreadable, invalid };
|
|
1232
|
+
}
|
|
1176
1233
|
/** An own org's Lettermint key is held. A resolver that throws counts as not held. */
|
|
1177
1234
|
async ownMailKeyHeld(org) {
|
|
1178
1235
|
const held = this.platformDeps?.heldKeys;
|
|
@@ -655,7 +655,17 @@ export declare function appliedAppDomains(org: DeclaredOrg, recorded: Readonly<R
|
|
|
655
655
|
/** Whether the secret sources can be filled right now: a new shared secret can be held. */
|
|
656
656
|
export interface AppSecretAvailability {
|
|
657
657
|
sharedSecret: boolean;
|
|
658
|
+
/** The env names of the entry's `heldSecret` values that are held now. Absent: none. */
|
|
659
|
+
heldSecret?: readonly string[];
|
|
658
660
|
}
|
|
661
|
+
/** What the plan decided can be filled for `entry`: the held secrets whose ref holds a value now. */
|
|
662
|
+
export declare function appSecretAvailability(org: DeclaredOrg, entry: HostingEntry, readiness: {
|
|
663
|
+
canHoldSecrets?: boolean;
|
|
664
|
+
heldEnv?: {
|
|
665
|
+
refs: readonly string[];
|
|
666
|
+
invalid?: readonly string[];
|
|
667
|
+
};
|
|
668
|
+
}): AppSecretAvailability;
|
|
659
669
|
/**
|
|
660
670
|
* The entry's `env` values that have a value, in env-name order, and the env
|
|
661
671
|
* names whose source has none yet. Shared by `planApp` and the executor's
|
|
@@ -675,6 +685,9 @@ export declare function resolveAppEnv(org: DeclaredOrg, entry: HostingEntry, rec
|
|
|
675
685
|
source: EnvSharedSecret;
|
|
676
686
|
}>;
|
|
677
687
|
unresolvedSecrets: Array<[string, string]>;
|
|
688
|
+
/** The env names of `heldSecret` values that are held, and those that are not. */
|
|
689
|
+
heldSecrets: string[];
|
|
690
|
+
unresolvedHeld: string[];
|
|
678
691
|
marker: Record<string, string>;
|
|
679
692
|
};
|
|
680
693
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { VERSIONS } from '../bootstrap/versions.js';
|
|
3
3
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
4
|
+
import { heldEnvRef } from '../org.js';
|
|
4
5
|
import { VERSION_CHANGE_KIND, } from '../plan.js';
|
|
5
6
|
import { buildDriftNotes } from './build-record.js';
|
|
6
7
|
import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
|
|
@@ -737,6 +738,19 @@ export function appliedAppDomains(org, recorded, entryKey) {
|
|
|
737
738
|
return JSON.parse(marker);
|
|
738
739
|
return entryKey === APP_ENTRY_KEY ? [legacyAppHostname(org)] : [];
|
|
739
740
|
}
|
|
741
|
+
/** What the plan decided can be filled for `entry`: the held secrets whose ref holds a value now. */
|
|
742
|
+
export function appSecretAvailability(org, entry, readiness) {
|
|
743
|
+
const refs = new Set(readiness.heldEnv?.refs ?? []);
|
|
744
|
+
return {
|
|
745
|
+
sharedSecret: readiness.canHoldSecrets === true,
|
|
746
|
+
heldSecret: Object.entries(entry.env)
|
|
747
|
+
.filter(([name, v]) => typeof v !== 'string' &&
|
|
748
|
+
v.from === 'heldSecret' &&
|
|
749
|
+
!readiness.heldEnv?.invalid?.includes(`${entry.key}/${name}`) &&
|
|
750
|
+
refs.has(heldEnvRef(org.id, entry.key, name)))
|
|
751
|
+
.map(([name]) => name),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
740
754
|
/**
|
|
741
755
|
* The entry's `env` values that have a value, in env-name order, and the env
|
|
742
756
|
* names whose source has none yet. Shared by `planApp` and the executor's
|
|
@@ -758,8 +772,19 @@ export function resolveAppEnv(org, entry, recorded, available = { sharedSecret:
|
|
|
758
772
|
const unresolved = [];
|
|
759
773
|
const secrets = [];
|
|
760
774
|
const unresolvedSecrets = [];
|
|
775
|
+
const heldSecrets = [];
|
|
776
|
+
const unresolvedHeld = [];
|
|
761
777
|
const marker = {};
|
|
762
778
|
for (const [name, declared] of Object.entries(entry.env).sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
779
|
+
if (typeof declared !== 'string' && declared.from === 'heldSecret') {
|
|
780
|
+
if (available.heldSecret?.includes(name)) {
|
|
781
|
+
heldSecrets.push(name);
|
|
782
|
+
marker[name] = `secret:heldSecret:${name}`;
|
|
783
|
+
}
|
|
784
|
+
else
|
|
785
|
+
unresolvedHeld.push(name);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
763
788
|
if (typeof declared !== 'string' && declared.from === 'sharedSecret') {
|
|
764
789
|
const label = `sharedSecret ${declared.name}`;
|
|
765
790
|
if (available.sharedSecret) {
|
|
@@ -778,7 +803,7 @@ export function resolveAppEnv(org, entry, recorded, available = { sharedSecret:
|
|
|
778
803
|
else if (typeof declared !== 'string')
|
|
779
804
|
unresolved.push([name, declared.from]);
|
|
780
805
|
}
|
|
781
|
-
return { values, unresolved, secrets, unresolvedSecrets, marker };
|
|
806
|
+
return { values, unresolved, secrets, unresolvedSecrets, heldSecrets, unresolvedHeld, marker };
|
|
782
807
|
}
|
|
783
808
|
/**
|
|
784
809
|
* #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
|
|
@@ -169,6 +169,20 @@ export interface OrgPlatformReadiness {
|
|
|
169
169
|
mailKeyHeld?: boolean;
|
|
170
170
|
/** The host's held keys can `put`, so an entry env from `sharedSecret` can be filled. Absent reads as false. */
|
|
171
171
|
canHoldSecrets?: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* The org's `heldSecret` env values. `store` is false when the host wired no held-keys reader;
|
|
174
|
+
* `refs` are the refs that hold a value now; `differs` are those whose live production env is not
|
|
175
|
+
* the held value (absent counts as different) and `unreadable` those whose live env could not be
|
|
176
|
+
* read; `invalid` are `<entryKey>/<NAME>` of envs that fit no ref. Refs and names only, never a
|
|
177
|
+
* value. Absent reads as no store.
|
|
178
|
+
*/
|
|
179
|
+
heldEnv?: {
|
|
180
|
+
store: boolean;
|
|
181
|
+
refs: readonly string[];
|
|
182
|
+
differs?: readonly string[];
|
|
183
|
+
unreadable?: readonly string[];
|
|
184
|
+
invalid?: readonly string[];
|
|
185
|
+
};
|
|
172
186
|
/**
|
|
173
187
|
* True when the host wired no held-keys reader, so no key could be checked.
|
|
174
188
|
* A missing key is then reported as "no reader given" instead of "not held".
|
|
@@ -105,6 +105,7 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
|
|
|
105
105
|
private readonly resolveRecordedIds;
|
|
106
106
|
constructor(client: CoolifyReaderClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
|
|
107
107
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
108
|
+
liveEnvEquals(orgId: OrgId, appKey: string, name: string, value: string): Promise<boolean>;
|
|
108
109
|
/** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
|
|
109
110
|
listProject(projectUuid: string): Promise<LiveResource[]>;
|
|
110
111
|
}
|
|
@@ -140,8 +140,9 @@ async function withLogVolumeConfig(client, resource, recorded) {
|
|
|
140
140
|
}
|
|
141
141
|
try {
|
|
142
142
|
const storages = await client.listStorages(resource.key);
|
|
143
|
-
const
|
|
144
|
-
|
|
143
|
+
const volumeMounts = (storages.persistent_storages ?? []).map((s) => s.mount_path);
|
|
144
|
+
const hasLogVolume = volumeMounts.includes(LOG_DIR);
|
|
145
|
+
return { ...resource, config: { ...resource.config, hasLogVolume, volumeMounts } };
|
|
145
146
|
}
|
|
146
147
|
catch {
|
|
147
148
|
return resource;
|
|
@@ -320,6 +321,13 @@ export class ProvidersCoolifyReader {
|
|
|
320
321
|
const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
|
|
321
322
|
return applyRecordedIds(withBouleConfig, recorded);
|
|
322
323
|
}
|
|
324
|
+
async liveEnvEquals(orgId, appKey, name, value) {
|
|
325
|
+
const recorded = await this.resolveRecordedIds(orgId);
|
|
326
|
+
const uuid = Object.entries(recorded).find(([, key]) => key === `app:${appKey}`)?.[0];
|
|
327
|
+
if (!uuid)
|
|
328
|
+
return false;
|
|
329
|
+
return (await this.client.listEnvs(uuid)).some((e) => e.key === name && !e.is_preview && e.value === value);
|
|
330
|
+
}
|
|
323
331
|
/** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
|
|
324
332
|
async listProject(projectUuid) {
|
|
325
333
|
return toFoundryResources('coolify', await this.client.listResources(projectUuid));
|