@wtfalch/foundry 0.16.6 → 0.16.8
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/bootstrap/bootstrap.d.ts +2 -0
- package/dist/bootstrap/bootstrap.js +9 -1
- package/dist/bootstrap/declare.d.ts +1 -1
- package/dist/bootstrap/declare.js +7 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/migrations/0036_applied_versions.sql +45 -0
- package/dist/migrations/0037_platform_server_ipv4.sql +8 -0
- package/dist/org.d.ts +39 -0
- package/dist/org.js +46 -1
- package/dist/plan/boule.d.ts +8 -2
- package/dist/plan/boule.js +10 -1
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +40 -5
- package/dist/plan/executor.d.ts +30 -6
- package/dist/plan/executor.js +86 -21
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/plan-apply.d.ts +21 -1
- package/dist/plan/plan-apply.js +38 -3
- package/dist/plan/platform-diff.d.ts +5 -1
- package/dist/plan/platform-diff.js +38 -7
- package/dist/plan/platform-providers.d.ts +51 -1
- package/dist/plan/platform-providers.js +327 -52
- package/dist/platform.d.ts +11 -0
- package/dist/platform.js +5 -0
- package/dist/store/postgres.d.ts +9 -1
- package/dist/store/postgres.js +36 -2
- package/dist/store/schema.d.ts +134 -0
- package/dist/store/schema.js +15 -0
- package/dist/store.d.ts +37 -0
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -143,6 +143,19 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
143
143
|
const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
144
144
|
/** The one Access policy foundry puts on its own Access app (#84). */
|
|
145
145
|
export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
|
|
146
|
+
/**
|
|
147
|
+
* The Access policy name for each `hosting.access` rule kind (#246),
|
|
148
|
+
* `cloudflareAccess`'s way of telling its own policy apart from a foreign
|
|
149
|
+
* one on the same app -- the same role {@link ACCESS_POLICY_NAME} plays for
|
|
150
|
+
* the `app` entry's own email gate, which keeps that exact name so a
|
|
151
|
+
* re-run against an org applied before #246 still recognises its own
|
|
152
|
+
* policy.
|
|
153
|
+
*/
|
|
154
|
+
const ACCESS_RULE_POLICY_NAMES = {
|
|
155
|
+
gate: ACCESS_POLICY_NAME,
|
|
156
|
+
bypass: 'foundry: bypass',
|
|
157
|
+
'service-token': 'foundry: service token',
|
|
158
|
+
};
|
|
146
159
|
/**
|
|
147
160
|
* The sign-in broker's own route mount (`@wtfalch/auth`'s `NextAuth`,
|
|
148
161
|
* `dist/next.d.ts`'s `handlers` doc comment: "`export const { GET, POST } =
|
|
@@ -635,7 +648,7 @@ export class FoundryStepExecutor {
|
|
|
635
648
|
case 'cloudflare:zone':
|
|
636
649
|
return this.cloudflareZone(action, org);
|
|
637
650
|
case 'cloudflare:access':
|
|
638
|
-
return this.cloudflareAccess(org);
|
|
651
|
+
return this.cloudflareAccess(action, org);
|
|
639
652
|
case 'cloudflare:ratelimit':
|
|
640
653
|
return this.cloudflareRateLimit(org);
|
|
641
654
|
case 'files:organisation':
|
|
@@ -677,40 +690,91 @@ export class FoundryStepExecutor {
|
|
|
677
690
|
}
|
|
678
691
|
}
|
|
679
692
|
/**
|
|
680
|
-
* `plan/diff.ts`'s `planAccess` (#84): puts
|
|
681
|
-
* Cloudflare Access app
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
693
|
+
* `plan/diff.ts`'s `planAccess` (#84, widened by #246): puts a hostname
|
|
694
|
+
* behind a Cloudflare Access app. `action.key` tells the `app` entry's own
|
|
695
|
+
* gate (`accessEmails`, email-only) apart from a `hosting.access` rule on
|
|
696
|
+
* any other hostname -- `appStepKey` builds both, keyed by the entry's key
|
|
697
|
+
* or by the rule's hostname respectively, and the two never collide
|
|
698
|
+
* because an entry key never holds a `.` while a rule's hostname always
|
|
699
|
+
* does (`org.ts`'s `validateAccessRule`). The app-entry gate keeps the
|
|
700
|
+
* Access app name `foundry:<orgId>` it has always used, so a re-run
|
|
701
|
+
* against an org applied before #246 still finds the app it already
|
|
702
|
+
* created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
|
|
703
|
+
* since an org may declare more than one. Either way foundry reuses only
|
|
704
|
+
* an app with its own name (so a re-run after a crash does not duplicate
|
|
705
|
+
* it), refuses to adopt a hostname another Access app already holds
|
|
706
|
+
* (destroy would later delete it), and refuses when its own app carries
|
|
686
707
|
* any policy but foundry's, since that policy could let others in.
|
|
687
708
|
*/
|
|
688
|
-
async cloudflareAccess(org) {
|
|
709
|
+
async cloudflareAccess(action, org) {
|
|
689
710
|
// Only the `app` entry has a public name (`planAccess`); per-entry
|
|
690
711
|
// hostnames are issue 309.
|
|
691
712
|
const entry = appEntry(org.hosting);
|
|
692
713
|
if (!entry)
|
|
693
714
|
throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
|
|
694
|
-
|
|
695
|
-
|
|
715
|
+
if (action.key === appStepKey(org, entry.key)) {
|
|
716
|
+
return this.cloudflareAccessApp(org, {
|
|
717
|
+
appName: `foundry:${org.id}`,
|
|
718
|
+
domain: appHostname(org),
|
|
719
|
+
policyName: ACCESS_POLICY_NAME,
|
|
720
|
+
policy: {
|
|
721
|
+
decision: 'allow',
|
|
722
|
+
include: entry.accessEmails.map((email) => ({ email: { email } })),
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
|
|
727
|
+
if (!rule) {
|
|
728
|
+
throw new Error(`cloudflare:access for ${org.slug}: no hosting.access rule matches step key '${action.key}'`);
|
|
729
|
+
}
|
|
730
|
+
return this.cloudflareAccessApp(org, {
|
|
731
|
+
appName: `foundry:${org.id}:${rule.hostname}`,
|
|
732
|
+
domain: rule.hostname,
|
|
733
|
+
policyName: ACCESS_RULE_POLICY_NAMES[rule.kind],
|
|
734
|
+
policy: await this.accessPolicyFor(org, rule),
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
/** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
|
|
738
|
+
async accessPolicyFor(org, rule) {
|
|
739
|
+
switch (rule.kind) {
|
|
740
|
+
case 'gate':
|
|
741
|
+
return {
|
|
742
|
+
decision: 'allow',
|
|
743
|
+
include: rule.emails.map((email) => ({ email: { email } })),
|
|
744
|
+
};
|
|
745
|
+
case 'bypass':
|
|
746
|
+
// A bypass policy still needs an `include` rule to match against;
|
|
747
|
+
// `everyone` is Cloudflare's own shape for "match every visitor".
|
|
748
|
+
return { decision: 'bypass', include: [{ everyone: {} }] };
|
|
749
|
+
case 'service-token': {
|
|
750
|
+
const token = await this.ports.cloudflare.findServiceTokenByName(rule.serviceTokenName);
|
|
751
|
+
if (!token) {
|
|
752
|
+
throw new Error(`cloudflare:access for ${org.slug}: no Cloudflare Access service token named '${rule.serviceTokenName}'`);
|
|
753
|
+
}
|
|
754
|
+
return { decision: 'non_identity', include: [{ service_token: { token_id: token.id } }] };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* The create-or-adopt-refusal logic `cloudflareAccess` shares between the
|
|
760
|
+
* `app` entry's own gate and every `hosting.access` rule (#84, #246).
|
|
761
|
+
*/
|
|
762
|
+
async cloudflareAccessApp(org, opts) {
|
|
763
|
+
const { appName, domain, policyName, policy } = opts;
|
|
696
764
|
const cf = this.ports.cloudflare;
|
|
697
765
|
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
698
|
-
const foreign = onDomain.find((a) => a.name !==
|
|
766
|
+
const foreign = onDomain.find((a) => a.name !== appName);
|
|
699
767
|
if (foreign) {
|
|
700
768
|
throw new Error(`cloudflare:access for ${org.slug}: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
|
|
701
769
|
}
|
|
702
|
-
const app = onDomain[0] ?? (await cf.createAccessApp({ name, domain, type: 'self_hosted' }));
|
|
770
|
+
const app = onDomain[0] ?? (await cf.createAccessApp({ name: appName, domain, type: 'self_hosted' }));
|
|
703
771
|
const policies = await cf.listAccessPolicies(app.id);
|
|
704
|
-
const other = policies.find((p) => p.name !==
|
|
772
|
+
const other = policies.find((p) => p.name !== policyName);
|
|
705
773
|
if (other) {
|
|
706
774
|
throw new Error(`cloudflare:access for ${org.slug}: Access app ${app.id} has policy '${other.name}' that foundry did not create`);
|
|
707
775
|
}
|
|
708
776
|
if (policies.length === 0) {
|
|
709
|
-
await cf.createAccessPolicy(app.id, {
|
|
710
|
-
name: ACCESS_POLICY_NAME,
|
|
711
|
-
decision: 'allow',
|
|
712
|
-
include: entry.accessEmails.map((email) => ({ email: { email } })),
|
|
713
|
-
});
|
|
777
|
+
await cf.createAccessPolicy(app.id, { name: policyName, ...policy });
|
|
714
778
|
}
|
|
715
779
|
return { resourceId: app.id };
|
|
716
780
|
}
|
|
@@ -3055,10 +3119,11 @@ export class FoundryStepExecutor {
|
|
|
3055
3119
|
* (`bouleBrokerEntry`).
|
|
3056
3120
|
*/
|
|
3057
3121
|
async coolifyBoule(action, org, context) {
|
|
3058
|
-
// The issuer the plan read, carried in the step: the env
|
|
3059
|
-
// env the plan showed.
|
|
3122
|
+
// The issuer and Archon URL the plan read, carried in the step: the env
|
|
3123
|
+
// written is the env the plan showed.
|
|
3060
3124
|
const issuer = action.desired.issuer ?? null;
|
|
3061
|
-
const
|
|
3125
|
+
const archonUrl = action.desired.archonUrl ?? null;
|
|
3126
|
+
const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl);
|
|
3062
3127
|
if (missing.length > 0) {
|
|
3063
3128
|
throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
3064
3129
|
}
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -35,4 +35,6 @@ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
|
|
|
35
35
|
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
36
36
|
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
|
|
37
37
|
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
|
38
|
+
export type { ZitadelListAppsRawApp } from './executor.js';
|
|
39
|
+
export { adaptZitadelListApps } from './executor.js';
|
|
38
40
|
export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -18,4 +18,5 @@ export { loginHostnameFor } from './login-host.js';
|
|
|
18
18
|
export { checkMailIdentityAudience } from './mail-identity.js';
|
|
19
19
|
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
20
20
|
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
|
21
|
+
export { adaptZitadelListApps } from './executor.js';
|
|
21
22
|
export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
|
|
@@ -3,7 +3,7 @@ import type { DeclaredOrg, 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 { AppliedState } from '../state.js';
|
|
6
|
-
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
6
|
+
import type { AppliedVersionsStore, DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
7
7
|
import { type AgoraHostPort } from './agora.js';
|
|
8
8
|
import { type StepAction } from './diff.js';
|
|
9
9
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
@@ -204,6 +204,8 @@ export interface PlatformStepExecutor {
|
|
|
204
204
|
driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
|
|
205
205
|
archon?: boolean;
|
|
206
206
|
auth?: boolean;
|
|
207
|
+
/** #380: see `PlanPlatformOptions.drifted.files` (`platform-diff.ts`). */
|
|
208
|
+
files?: boolean;
|
|
207
209
|
backupStorage?: boolean;
|
|
208
210
|
/** #257 fix round 4: see `PlanPlatformOptions.drifted.backupStorageBlocked` (`platform-diff.ts`). */
|
|
209
211
|
backupStorageBlocked?: string;
|
|
@@ -315,6 +317,13 @@ export interface FoundryPlanApplyOptions {
|
|
|
315
317
|
aiOrganisation?: boolean;
|
|
316
318
|
/** Overridable for tests; defaults to `() => new Date()`. */
|
|
317
319
|
now?: () => Date;
|
|
320
|
+
/**
|
|
321
|
+
* #253: appends a history row every time `runSteps` records a new
|
|
322
|
+
* version as applied, beside the single-snapshot `store.putAppliedState`
|
|
323
|
+
* write that already happens there. Optional, and a no-op without it --
|
|
324
|
+
* every existing caller of this constructor is unaffected.
|
|
325
|
+
*/
|
|
326
|
+
appliedVersions?: AppliedVersionsStore;
|
|
318
327
|
/**
|
|
319
328
|
* #167: dependencies for planning/applying the shared platform (#166)
|
|
320
329
|
* alongside an org. Optional so an org-only caller — every #19-era caller,
|
|
@@ -364,6 +373,7 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
364
373
|
private readonly hostFiles;
|
|
365
374
|
private readonly hostAi;
|
|
366
375
|
private readonly now;
|
|
376
|
+
private readonly appliedVersions;
|
|
367
377
|
private readonly platformDeps;
|
|
368
378
|
constructor(options: FoundryPlanApplyOptions);
|
|
369
379
|
plan(id: OrgId): Promise<Plan>;
|
|
@@ -422,6 +432,16 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
422
432
|
* (perhaps not yet wired to this platform) keeps them.
|
|
423
433
|
*/
|
|
424
434
|
private orgAgoraHost;
|
|
435
|
+
/**
|
|
436
|
+
* #374: the Archon that bills every org, written into each Boule as
|
|
437
|
+
* `ARCHON_URL` -- `https://` plus the declared platform's `archon.hostname`
|
|
438
|
+
* (today `archon.companyincluded.com`), the same `platformDeps`
|
|
439
|
+
* {@link orgAgoraHost} reads. `null` with no platform declared, Archon not
|
|
440
|
+
* enabled, or Archon not yet applied -- the same `enabled && applied` gate
|
|
441
|
+
* {@link agoraHostValuesFromPlatform} uses for `issuer`, so a Boule never
|
|
442
|
+
* points at an Archon that is not live yet.
|
|
443
|
+
*/
|
|
444
|
+
private orgArchonUrl;
|
|
425
445
|
/** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
|
|
426
446
|
private requirePlatformDeps;
|
|
427
447
|
private computeFreshPlatformPlan;
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -8,7 +8,7 @@ import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
|
|
|
8
8
|
import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
|
|
9
9
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
10
10
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
11
|
-
import { planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
11
|
+
import { PLATFORM_ARCHON_KEY, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
12
12
|
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
13
13
|
export class DeclaredOrgNotFoundError extends Error {
|
|
14
14
|
constructor(orgId) {
|
|
@@ -98,6 +98,7 @@ export class FoundryPlanApply {
|
|
|
98
98
|
hostFiles;
|
|
99
99
|
hostAi;
|
|
100
100
|
now;
|
|
101
|
+
appliedVersions;
|
|
101
102
|
platformDeps;
|
|
102
103
|
constructor(options) {
|
|
103
104
|
this.store = options.store;
|
|
@@ -114,6 +115,7 @@ export class FoundryPlanApply {
|
|
|
114
115
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
115
116
|
this.hostAi = options.aiOrganisation ?? false;
|
|
116
117
|
this.now = options.now ?? (() => new Date());
|
|
118
|
+
this.appliedVersions = options.appliedVersions;
|
|
117
119
|
this.platformDeps = options.platform;
|
|
118
120
|
}
|
|
119
121
|
async plan(id) {
|
|
@@ -344,14 +346,25 @@ export class FoundryPlanApply {
|
|
|
344
346
|
});
|
|
345
347
|
throw e;
|
|
346
348
|
}
|
|
349
|
+
const lastApplied = { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) };
|
|
347
350
|
const applied = {
|
|
348
351
|
orgId,
|
|
349
352
|
serviceIds,
|
|
350
353
|
checkpoints,
|
|
351
354
|
failedFields: failedFields(org, detail, detail.plannedSteps.length, serviceIds),
|
|
352
|
-
lastApplied
|
|
355
|
+
lastApplied,
|
|
353
356
|
};
|
|
354
357
|
await this.store.putAppliedState(applied);
|
|
358
|
+
// #253: the single-snapshot write just above replaces whatever
|
|
359
|
+
// `lastApplied` this org had; this is the point in today's code where a
|
|
360
|
+
// version is recorded as applied, so it's also where the appended
|
|
361
|
+
// history row goes. Optional, and a no-op without it, like every other
|
|
362
|
+
// host-supplied dependency on `FoundryPlanApplyOptions`.
|
|
363
|
+
await this.appliedVersions?.record({
|
|
364
|
+
service: orgId,
|
|
365
|
+
ref: lastApplied.declaredOrgVersion,
|
|
366
|
+
appliedAt: lastApplied.at,
|
|
367
|
+
});
|
|
355
368
|
await this.audit.recordApply({
|
|
356
369
|
actor: this.actor,
|
|
357
370
|
orgId,
|
|
@@ -365,11 +378,12 @@ export class FoundryPlanApply {
|
|
|
365
378
|
const org = await this.store.get(id);
|
|
366
379
|
if (!org)
|
|
367
380
|
throw new DeclaredOrgNotFoundError(id);
|
|
368
|
-
const [live, applied, platformReadiness, agoraHost] = await Promise.all([
|
|
381
|
+
const [live, applied, platformReadiness, agoraHost, archonUrl] = await Promise.all([
|
|
369
382
|
readLiveResources(id, this.readers),
|
|
370
383
|
this.store.getAppliedState(id),
|
|
371
384
|
this.orgPlatformReadiness(),
|
|
372
385
|
this.orgAgoraHost(id),
|
|
386
|
+
this.orgArchonUrl(),
|
|
373
387
|
]);
|
|
374
388
|
const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
|
|
375
389
|
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
@@ -377,6 +391,7 @@ export class FoundryPlanApply {
|
|
|
377
391
|
broker: this.broker,
|
|
378
392
|
otherSlugs,
|
|
379
393
|
now: () => this.now().getTime(),
|
|
394
|
+
archonUrl,
|
|
380
395
|
});
|
|
381
396
|
const invalid = await this.invalidHosting(org);
|
|
382
397
|
if (invalid) {
|
|
@@ -448,6 +463,26 @@ export class FoundryPlanApply {
|
|
|
448
463
|
const recorded = await this.platformDeps.store.getServiceIds();
|
|
449
464
|
return agoraHostValuesFromPlatform(platform, recorded);
|
|
450
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* #374: the Archon that bills every org, written into each Boule as
|
|
468
|
+
* `ARCHON_URL` -- `https://` plus the declared platform's `archon.hostname`
|
|
469
|
+
* (today `archon.companyincluded.com`), the same `platformDeps`
|
|
470
|
+
* {@link orgAgoraHost} reads. `null` with no platform declared, Archon not
|
|
471
|
+
* enabled, or Archon not yet applied -- the same `enabled && applied` gate
|
|
472
|
+
* {@link agoraHostValuesFromPlatform} uses for `issuer`, so a Boule never
|
|
473
|
+
* points at an Archon that is not live yet.
|
|
474
|
+
*/
|
|
475
|
+
async orgArchonUrl() {
|
|
476
|
+
if (!this.platformDeps)
|
|
477
|
+
return null;
|
|
478
|
+
const platform = await this.platformDeps.store.get();
|
|
479
|
+
if (!platform || !platform.archon.enabled)
|
|
480
|
+
return null;
|
|
481
|
+
const recorded = await this.platformDeps.store.getServiceIds();
|
|
482
|
+
if (!recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`])
|
|
483
|
+
return null;
|
|
484
|
+
return `https://${platform.archon.hostname}`;
|
|
485
|
+
}
|
|
451
486
|
/** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
|
|
452
487
|
requirePlatformDeps() {
|
|
453
488
|
if (!this.platformDeps) {
|
|
@@ -579,11 +579,15 @@ export interface PlanPlatformOptions {
|
|
|
579
579
|
* check itself failed (Coolify unreachable, credentials revoked) -- the
|
|
580
580
|
* plan is never failed over it, but it is reported in `unmanaged` rather
|
|
581
581
|
* than only logged, so a persistent failure is visible instead of silently
|
|
582
|
-
* retried forever.
|
|
582
|
+
* retried forever. `files` (#380): the runner's live env is missing a
|
|
583
|
+
* setting `ensureStarted()` requires -- see `filesRunnerEnvDrifted`
|
|
584
|
+
* (`platform-providers.ts`) -- which the recorded hostname/gitRef alone
|
|
585
|
+
* could never show.
|
|
583
586
|
*/
|
|
584
587
|
drifted?: {
|
|
585
588
|
archon?: boolean;
|
|
586
589
|
auth?: boolean;
|
|
590
|
+
files?: boolean;
|
|
587
591
|
backupStorage?: boolean;
|
|
588
592
|
backupStorageBlocked?: string;
|
|
589
593
|
};
|
|
@@ -625,7 +625,7 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
625
625
|
// #171: after auth, so a first apply builds the sign-in the files step
|
|
626
626
|
// creates its ZITADEL project in before that step runs.
|
|
627
627
|
const authReady = auth.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]);
|
|
628
|
-
const files = planFiles(platform, recordedIds, blocked.files, authReady);
|
|
628
|
+
const files = planFiles(platform, recordedIds, blocked.files, authReady, drifted.files);
|
|
629
629
|
if (files.step)
|
|
630
630
|
plannedSteps.push(files.step);
|
|
631
631
|
// #169: the platform's own mail server. After auth, because its OIDC
|
|
@@ -1049,6 +1049,10 @@ function planSfu(platform) {
|
|
|
1049
1049
|
* the broker's own token as part of running this step, and a step that is
|
|
1050
1050
|
* never planned never runs. So this still plans the step -- surfacing
|
|
1051
1051
|
* `executorBlocked` in `blocked` for visibility, not to stop planning.
|
|
1052
|
+
* Exception (the auth-broker loop): once everything else is applied and
|
|
1053
|
+
* `otherInputsMissing` is non-empty, nothing this step can do changes the
|
|
1054
|
+
* missing inputs, so it is reported blocked and not planned -- else every
|
|
1055
|
+
* `driveApply` round replans it until `maxRounds`.
|
|
1052
1056
|
*
|
|
1053
1057
|
* #211 round 6: round 3's `!brokerApplied` guard assumed "no executor" and
|
|
1054
1058
|
* "broker not yet created" always coincide. They don't: a broker recorded on
|
|
@@ -1159,10 +1163,25 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
|
|
|
1159
1163
|
// #211 round 3: a blocked reason with no broker yet created also means the
|
|
1160
1164
|
// executor is unusable (no auth dependencies) -- nothing to plan.
|
|
1161
1165
|
// A blocked reason with the broker already created means only its deploy
|
|
1162
|
-
// is held back; plan the step anyway so it can
|
|
1163
|
-
// #
|
|
1166
|
+
// is held back; plan the step anyway, so it can mint what it can (#209) and,
|
|
1167
|
+
// once #210 lands, deploy on the very next apply.
|
|
1164
1168
|
if (executorBlocked && !brokerApplied)
|
|
1165
1169
|
return { step: null, blocked: executorBlocked };
|
|
1170
|
+
// Loop fix: everything but the broker deploy is applied and that deploy
|
|
1171
|
+
// waits on an input (`otherInputsMissing`, never the token this step mints)
|
|
1172
|
+
// that another run supplies -- re-applying changes nothing, so the step
|
|
1173
|
+
// planned and applied every `driveApply` round, with dozens of Coolify
|
|
1174
|
+
// calls each, until `maxRounds`. Report it blocked and plan nothing; the
|
|
1175
|
+
// next plan re-reads the live inputs, so it plans the step once they exist.
|
|
1176
|
+
// The login client is not required here: `provisionPlatformAuth` mints it
|
|
1177
|
+
// only once those same inputs exist, so it is never recorded before then.
|
|
1178
|
+
if (executorBlocked &&
|
|
1179
|
+
otherInputsMissing.length > 0 &&
|
|
1180
|
+
recordedIds[key] &&
|
|
1181
|
+
connectorApplied &&
|
|
1182
|
+
recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
|
|
1183
|
+
return { step: null, blocked: executorBlocked };
|
|
1184
|
+
}
|
|
1166
1185
|
return {
|
|
1167
1186
|
step: {
|
|
1168
1187
|
action: {
|
|
@@ -1373,12 +1392,18 @@ function planIntegrations(platform, recordedIds, executorBlocked) {
|
|
|
1373
1392
|
* `gitRef`, for the reason {@link planAuth} gives: no platform reader reports
|
|
1374
1393
|
* these resources, so a live check would replan the step forever. A changed
|
|
1375
1394
|
* hostname or `gitRef` plans the step again (a redeploy, no new secrets).
|
|
1395
|
+
* `drifted` (#380, `FoundryPlatformStepExecutor.driftedSteps`'s own live
|
|
1396
|
+
* check on the runner's env) replans it too, with neither changed: this is
|
|
1397
|
+
* how a setting {@link provisionPlatformFiles} only started setting after
|
|
1398
|
+
* the runner was already recorded applied -- `R2_BUCKET`, `R2_ENDPOINT` and
|
|
1399
|
+
* `KEYS_VERIFY_KEYS` among them -- ever reaches a runner that deployed
|
|
1400
|
+
* before.
|
|
1376
1401
|
*
|
|
1377
1402
|
* Blocked, not planned, while the platform's sign-in is neither recorded nor
|
|
1378
1403
|
* planned in this same plan: the step creates its ZITADEL project and client
|
|
1379
1404
|
* there, so it would throw on every apply.
|
|
1380
1405
|
*/
|
|
1381
|
-
function planFiles(platform, recordedIds, executorBlocked, authReady) {
|
|
1406
|
+
function planFiles(platform, recordedIds, executorBlocked, authReady, drifted) {
|
|
1382
1407
|
if (!platform.files.enabled)
|
|
1383
1408
|
return { step: null, blocked: null };
|
|
1384
1409
|
// A secret minted but never delivered and written is lost (the private key
|
|
@@ -1404,11 +1429,17 @@ function planFiles(platform, recordedIds, executorBlocked, authReady) {
|
|
|
1404
1429
|
};
|
|
1405
1430
|
}
|
|
1406
1431
|
}
|
|
1407
|
-
|
|
1432
|
+
const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_FILES_KEY)]) &&
|
|
1408
1433
|
recordedIds[PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY] === platform.files.hostname &&
|
|
1409
|
-
recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] === platform.files.gitRef
|
|
1434
|
+
recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] === platform.files.gitRef;
|
|
1435
|
+
// #380: applied is not enough on its own -- the runner's live env can be
|
|
1436
|
+
// missing a setting `ensureStarted()` requires (`driftedSteps`'s own
|
|
1437
|
+
// `filesRunnerEnvDrifted`), the exact gap that left the platform's own
|
|
1438
|
+
// files host recorded applied and `running:unhealthy` at once. Drifted
|
|
1439
|
+
// replans the step so `provisionPlatformFiles` (which redeploys
|
|
1440
|
+
// unconditionally on every run) fills in whatever is still missing.
|
|
1441
|
+
if (applied && !drifted)
|
|
1410
1442
|
return { step: null, blocked: null };
|
|
1411
|
-
}
|
|
1412
1443
|
if (executorBlocked)
|
|
1413
1444
|
return { step: null, blocked: executorBlocked };
|
|
1414
1445
|
if (!authReady) {
|
|
@@ -207,6 +207,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
|
|
|
207
207
|
driftedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
|
|
208
208
|
archon?: boolean;
|
|
209
209
|
auth?: boolean;
|
|
210
|
+
files?: boolean;
|
|
210
211
|
backupStorage?: boolean;
|
|
211
212
|
backupStorageBlocked?: string;
|
|
212
213
|
}>;
|
|
@@ -645,13 +646,62 @@ export interface PlatformFilesDeps {
|
|
|
645
646
|
* The keys worker the files host verifies and seals with, from the host's
|
|
646
647
|
* held keys (`KEYS_WORKER_URL`, `KEYS_CREDENTIAL`). The `files-migrate` app
|
|
647
648
|
* never becomes healthy without them, so the step sets both on both apps,
|
|
648
|
-
* and the executor reports the step blocked while
|
|
649
|
+
* and the executor reports the step blocked while either is unset. Never
|
|
649
650
|
* recorded or logged.
|
|
651
|
+
*
|
|
652
|
+
* #380: `verifyKeys` (`KEYS_VERIFY_KEYS`) is the same kind of requirement,
|
|
653
|
+
* but only on the `files` runner, not `files-migrate` -- the runner's own
|
|
654
|
+
* `ensureStarted()` (`apps/host/src/lib/authz/files-host.ts`) requires it
|
|
655
|
+
* with `requireEnv(...)` to build its keys issuer, and the step reports
|
|
656
|
+
* the whole thing blocked while it is unset, the same as a missing
|
|
657
|
+
* `workerUrl`/`credential`. `auditToken` (`KEYS_AUDIT_TOKEN`) is not: it
|
|
658
|
+
* gates only the keys repo's own reconcile job against
|
|
659
|
+
* `GET /v1/keys/audit`, so it is set on the runner only when supplied and
|
|
660
|
+
* never blocks the step.
|
|
650
661
|
*/
|
|
651
662
|
keys?: {
|
|
652
663
|
workerUrl: string;
|
|
653
664
|
credential: string;
|
|
665
|
+
verifyKeys?: string;
|
|
666
|
+
auditToken?: string;
|
|
654
667
|
};
|
|
668
|
+
/**
|
|
669
|
+
* #380: the object storage (Cloudflare R2) the `files` runner stores
|
|
670
|
+
* objects in -- `R2_BUCKET`/`R2_ENDPOINT`, both `required(...)` by
|
|
671
|
+
* `ensureStarted()`. The actual R2 access key and secret are not here:
|
|
672
|
+
* the host reads them back through the keys worker above at runtime
|
|
673
|
+
* (files' own `.env.example`), never a plaintext env var this step would
|
|
674
|
+
* set. Absent, the step reports the runner blocked the same way a
|
|
675
|
+
* missing `keys` already does -- issue #380's "Decide" leaves open
|
|
676
|
+
* whether foundry ever mints this bucket itself (today, only a host that
|
|
677
|
+
* already has one can supply it).
|
|
678
|
+
*/
|
|
679
|
+
r2?: {
|
|
680
|
+
bucket: string;
|
|
681
|
+
endpoint: string;
|
|
682
|
+
};
|
|
683
|
+
/**
|
|
684
|
+
* #380: `FILES_CLAMD_HOST`/`FILES_CLAMD_PORT` -- a clamd daemon reachable
|
|
685
|
+
* on the Coolify network. Optional: unset keeps files' own default
|
|
686
|
+
* (`confirm()` runs unscanned), and never blocks the step. Issue #380's
|
|
687
|
+
* "Decide" leaves open which clamd this should point at.
|
|
688
|
+
*/
|
|
689
|
+
clamd?: {
|
|
690
|
+
host: string;
|
|
691
|
+
port?: string;
|
|
692
|
+
};
|
|
693
|
+
/**
|
|
694
|
+
* #380: `FILES_PUBLIC_HOSTS`, the public share hosts this deployment
|
|
695
|
+
* answers on, comma-separated bare host names. Optional; unset keeps
|
|
696
|
+
* `/s/<token>` 404 everywhere, today's behaviour.
|
|
697
|
+
*/
|
|
698
|
+
publicHosts?: string;
|
|
699
|
+
/**
|
|
700
|
+
* #380: `JOBS_SIGNING_SECRETS`, the scheduled-jobs project's own HMAC
|
|
701
|
+
* secret(s), comma-separated, current first. Optional; unset keeps every
|
|
702
|
+
* `/api/jobs/*` route answering 503 `not_configured`, today's behaviour.
|
|
703
|
+
*/
|
|
704
|
+
jobsSigningSecrets?: string;
|
|
655
705
|
/** Clock and sleep for the migration wait and delivery retries; real ones by default. */
|
|
656
706
|
runtime?: PlatformFilesRuntime;
|
|
657
707
|
}
|