@wtfalch/foundry 0.4.0 → 0.6.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/bootstrap/bootstrap.d.ts +111 -0
- package/dist/bootstrap/bootstrap.js +323 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +99 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +17 -6
- package/dist/calls-sfu-deploy-support.js +19 -7
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +41 -9
- package/dist/index.d.ts +38 -7
- package/dist/index.js +20 -4
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/org.d.ts +81 -0
- package/dist/org.js +96 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +713 -115
- package/dist/plan/executor.d.ts +680 -172
- package/dist/plan/executor.js +755 -189
- package/dist/plan/index.d.ts +28 -5
- package/dist/plan/index.js +15 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +197 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +487 -0
- package/dist/plan/platform-diff.js +1220 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +121 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +615 -0
- package/dist/plan/platform-providers.js +4410 -0
- package/dist/plan/platform-sfu.d.ts +128 -0
- package/dist/plan/platform-sfu.js +142 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +83 -0
- package/dist/plan/zitadel-machine-user.js +238 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +118 -0
- package/dist/requested-changes.js +125 -0
- package/dist/slug.js +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +203 -4
- package/dist/store/schema.d.ts +755 -0
- package/dist/store/schema.js +70 -0
- package/dist/store.d.ts +53 -0
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { MigrateSql } from '../migrate.js';
|
|
2
|
+
import type { PlatformLiveReaders, PlatformStepExecutor } from '../plan/index.js';
|
|
3
|
+
import type { FoundryPlatformStepExecutorOptions } from '../plan/platform-providers.js';
|
|
4
|
+
import type { FoundryStoreDb } from '../store/postgres.js';
|
|
5
|
+
import type { BootstrapCompany, BootstrapOwner } from './declare.js';
|
|
6
|
+
export interface BootstrapPlatformInput {
|
|
7
|
+
company: BootstrapCompany;
|
|
8
|
+
owner: BootstrapOwner;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The master keys, read from the host's held keys by the caller.
|
|
12
|
+
*
|
|
13
|
+
* #213: `coolifyUrl`, `coolifyToken` and `cloudflareApiToken` are also what
|
|
14
|
+
* `bootstrapPlatform` puts as Archon's own held keys
|
|
15
|
+
* (`platform:master:coolify-url`, `-coolify-token`, `-cloudflare-token`)
|
|
16
|
+
* before Archon's app deploys, so its onboarding can read them back.
|
|
17
|
+
* `cloudflareApiToken` needs "API Tokens Write" for that: Archon mints its
|
|
18
|
+
* own scoped child tokens from it.
|
|
19
|
+
*/
|
|
20
|
+
export interface RootCredentials {
|
|
21
|
+
coolifyUrl: string;
|
|
22
|
+
coolifyToken: string;
|
|
23
|
+
cloudflareApiToken: string;
|
|
24
|
+
cloudflareAccountId: string;
|
|
25
|
+
/** Headers for a Coolify panel behind Cloudflare Access. */
|
|
26
|
+
accessHeaders?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
/** What Archon's onboarding screen shows: one event per phase or round. */
|
|
29
|
+
export interface BootstrapProgress {
|
|
30
|
+
phase: 'migrate' | 'declare' | 'plan' | 'apply' | 'done';
|
|
31
|
+
round?: number;
|
|
32
|
+
message: string;
|
|
33
|
+
steps?: string[];
|
|
34
|
+
}
|
|
35
|
+
/** Everything foundry's per-step executors take besides the two clients and `deliverSecret`. */
|
|
36
|
+
export type BootstrapExecutorDeps = Omit<FoundryPlatformStepExecutorOptions, 'coolify' | 'deliverSecret'>;
|
|
37
|
+
export interface BootstrapPlatformDeps {
|
|
38
|
+
/** Foundry's tables live here; the store, the audit log and the plan all use it. */
|
|
39
|
+
db: FoundryStoreDb;
|
|
40
|
+
/** The same database, for the migration runner. */
|
|
41
|
+
migrateSql: MigrateSql;
|
|
42
|
+
rootCredentials: () => Promise<RootCredentials>;
|
|
43
|
+
deliverSecret: (ref: string, secret: string) => Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* `put` (#213) is optional, like `PlatformArchonDeps.heldKeys`'s own: a
|
|
46
|
+
* host without one still compiles, and Archon's master keys are reported
|
|
47
|
+
* blocked, not skipped, while it is missing.
|
|
48
|
+
*/
|
|
49
|
+
heldKeys: {
|
|
50
|
+
resolve(ref: string): Promise<string | null>;
|
|
51
|
+
put?(ref: string, value: string): Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
/** Per-step dependencies (auth, mail, files, ai, integrations, archon, sfu). A step without its own is reported blocked. */
|
|
54
|
+
executor?: BootstrapExecutorDeps;
|
|
55
|
+
onProgress?: (event: BootstrapProgress) => void;
|
|
56
|
+
/** Rounds before the run gives up. Default 20. */
|
|
57
|
+
maxRounds?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Replaces the live readers and step executor foundry builds from the root
|
|
60
|
+
* credentials. It receives the redacting `deliverSecret` and `heldKeys`, so
|
|
61
|
+
* whatever it delivers or resolves is covered. For tests and hosts with
|
|
62
|
+
* their own clients.
|
|
63
|
+
*/
|
|
64
|
+
runtime?: (ports: {
|
|
65
|
+
credentials: RootCredentials;
|
|
66
|
+
deliverSecret: (ref: string, secret: string) => Promise<void>;
|
|
67
|
+
heldKeys: BootstrapPlatformDeps['heldKeys'];
|
|
68
|
+
}) => {
|
|
69
|
+
readers: PlatformLiveReaders;
|
|
70
|
+
executor: PlatformStepExecutor;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export interface BootstrapResult {
|
|
74
|
+
/** Step descriptions applied across every round, in order. */
|
|
75
|
+
applied: string[];
|
|
76
|
+
/** Steps the plan reported blocked, by service name (`UnmanagedItem.key`), with the planner's message. */
|
|
77
|
+
blocked: Array<{
|
|
78
|
+
step: string;
|
|
79
|
+
message: string;
|
|
80
|
+
}>;
|
|
81
|
+
/** The one step that threw (`apply` when the failure was outside any step), or `rounds` at the cap. */
|
|
82
|
+
failed?: {
|
|
83
|
+
step: string;
|
|
84
|
+
message: string;
|
|
85
|
+
};
|
|
86
|
+
/** True only when every declared step is applied and nothing is blocked or failed. */
|
|
87
|
+
complete: boolean;
|
|
88
|
+
}
|
|
89
|
+
/** Replaces every collected secret in a string. Secrets are added as they appear, so later output is covered too. */
|
|
90
|
+
export declare class Redactor {
|
|
91
|
+
private readonly secrets;
|
|
92
|
+
add(...values: Array<string | null | undefined>): void;
|
|
93
|
+
/** Adds every string leaf of a value (skipping functions, capped in depth) that is long enough to be a secret and not a plain URL. */
|
|
94
|
+
addDeep(value: unknown, depth?: number, seen?: Set<unknown>): void;
|
|
95
|
+
text(input: string): string;
|
|
96
|
+
}
|
|
97
|
+
/** Wraps the secret-carrying ports inside one step's deps so what they deliver or resolve is redacted. */
|
|
98
|
+
export declare function guardStepDeps(executor: BootstrapExecutorDeps | undefined, redactor: Redactor, deliverSecret: (ref: string, secret: string) => Promise<void>, heldKeys: BootstrapPlatformDeps['heldKeys']): BootstrapExecutorDeps;
|
|
99
|
+
/**
|
|
100
|
+
* The platform's first apply, run from Archon's onboarding flow: migrates
|
|
101
|
+
* foundry's tables, declares Company Included and the platform (never
|
|
102
|
+
* overwriting a row already there), then plans and applies round by round
|
|
103
|
+
* until every step is applied or reported blocked. Reads no env and no file:
|
|
104
|
+
* the database, root credentials, held keys and every per-step dependency
|
|
105
|
+
* come in through `deps`.
|
|
106
|
+
*
|
|
107
|
+
* Every root credential, every secret delivered and every held key resolved
|
|
108
|
+
* during the run is redacted from progress events, the result and any error
|
|
109
|
+
* thrown. Running it again applies nothing already applied.
|
|
110
|
+
*/
|
|
111
|
+
export declare function bootstrapPlatform(input: BootstrapPlatformInput, deps: BootstrapPlatformDeps): Promise<BootstrapResult>;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
2
|
+
import { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
3
|
+
import { migrate } from '../migrate.js';
|
|
4
|
+
import { FoundryPlanApply, FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, } from '../plan/index.js';
|
|
5
|
+
import { createPostgresAuditLog } from '../store/audit.js';
|
|
6
|
+
import { createPostgresDeclaredOrgStore, createPostgresPlatformStore } from '../store/postgres.js';
|
|
7
|
+
import { COMPANY_INCLUDED_ORG_ID, declaredCompanyOrg, declaredPlatformFor, ownerIdentity, } from './declare.js';
|
|
8
|
+
import { driveApply } from './drive.js';
|
|
9
|
+
const REDACTED = '<redacted>';
|
|
10
|
+
/** Shorter strings in `deps.executor` are settings ("main", ports), not secrets, and would mangle every message. */
|
|
11
|
+
const MIN_DEEP_SECRET = 8;
|
|
12
|
+
const MAX_DEEP_DEPTH = 6;
|
|
13
|
+
/** A URL with no userinfo carries no secret and appears in ordinary step text. */
|
|
14
|
+
const PLAIN_URL = /^https?:\/\/[^@\s]*$/;
|
|
15
|
+
/** Replaces every collected secret in a string. Secrets are added as they appear, so later output is covered too. */
|
|
16
|
+
export class Redactor {
|
|
17
|
+
secrets = new Set();
|
|
18
|
+
add(...values) {
|
|
19
|
+
for (const value of values)
|
|
20
|
+
if (value)
|
|
21
|
+
this.secrets.add(value);
|
|
22
|
+
}
|
|
23
|
+
/** Adds every string leaf of a value (skipping functions, capped in depth) that is long enough to be a secret and not a plain URL. */
|
|
24
|
+
addDeep(value, depth = 0, seen = new Set()) {
|
|
25
|
+
if (typeof value === 'string') {
|
|
26
|
+
if (value.length >= MIN_DEEP_SECRET && !PLAIN_URL.test(value))
|
|
27
|
+
this.secrets.add(value);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (depth >= MAX_DEEP_DEPTH || value === null || typeof value !== 'object' || seen.has(value)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
seen.add(value);
|
|
34
|
+
for (const child of Object.values(value))
|
|
35
|
+
this.addDeep(child, depth + 1, seen);
|
|
36
|
+
}
|
|
37
|
+
text(input) {
|
|
38
|
+
let out = input;
|
|
39
|
+
for (const secret of [...this.secrets].sort((a, b) => b.length - a.length)) {
|
|
40
|
+
out = out.split(secret).join(REDACTED);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const allowPlatformApply = {
|
|
46
|
+
async canApply() {
|
|
47
|
+
return false;
|
|
48
|
+
},
|
|
49
|
+
async canDestroy() {
|
|
50
|
+
return false;
|
|
51
|
+
},
|
|
52
|
+
async canApplyPlatform() {
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
/** `FoundryPlanApply` needs org-scoped parts this run never uses; each refuses if called. */
|
|
57
|
+
const unusedOrgExecutor = {
|
|
58
|
+
async execute() {
|
|
59
|
+
throw new Error('bootstrapPlatform never applies an org');
|
|
60
|
+
},
|
|
61
|
+
async destroy() {
|
|
62
|
+
throw new Error('bootstrapPlatform never destroys an org');
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const unusedOrgReader = { listResources: async () => [] };
|
|
66
|
+
/** The audit log with every free-text field run through the redactor before it is stored. */
|
|
67
|
+
function redactingAudit(inner, redactor) {
|
|
68
|
+
const plan = (p) => ({
|
|
69
|
+
...p,
|
|
70
|
+
steps: p.steps.map((s) => ({ ...s, description: redactor.text(s.description) })),
|
|
71
|
+
unmanaged: p.unmanaged.map((u) => ({ ...u, description: redactor.text(u.description) })),
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
recordPlan: (entry) => inner.recordPlan({ ...entry, plan: plan(entry.plan) }),
|
|
75
|
+
recordApply: (entry) => inner.recordApply({
|
|
76
|
+
...entry,
|
|
77
|
+
plan: plan(entry.plan),
|
|
78
|
+
...(entry.reason === undefined ? {} : { reason: redactor.text(entry.reason) }),
|
|
79
|
+
}),
|
|
80
|
+
recordDestroy: (entry) => inner.recordDestroy({
|
|
81
|
+
...entry,
|
|
82
|
+
...(entry.reason === undefined ? {} : { reason: redactor.text(entry.reason) }),
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Wraps the secret-carrying ports inside one step's deps so what they deliver or resolve is redacted. */
|
|
87
|
+
export function guardStepDeps(executor, redactor, deliverSecret, heldKeys) {
|
|
88
|
+
const out = { ...executor };
|
|
89
|
+
const minter = executor?.ai?.managementKeyMinter;
|
|
90
|
+
if (executor?.ai && minter) {
|
|
91
|
+
out.ai = {
|
|
92
|
+
...executor.ai,
|
|
93
|
+
managementKeyMinter: {
|
|
94
|
+
async mintManagementKey(input) {
|
|
95
|
+
const minted = await minter.mintManagementKey(input);
|
|
96
|
+
redactor.add(minted.secret);
|
|
97
|
+
return minted;
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
for (const key of ['auth', 'mail', 'integrations', 'files', 'ai', 'archon']) {
|
|
103
|
+
const deps = out[key];
|
|
104
|
+
if (!deps)
|
|
105
|
+
continue;
|
|
106
|
+
const copy = { ...deps };
|
|
107
|
+
if ('deliverSecret' in copy)
|
|
108
|
+
copy.deliverSecret = deliverSecret;
|
|
109
|
+
// #201: ai's own default minter needs heldKeys too, to re-sign a mint
|
|
110
|
+
// request against its signing key when an earlier run pinned it but
|
|
111
|
+
// crashed before the management key itself was minted and delivered.
|
|
112
|
+
if (key === 'archon' || key === 'ai' || 'heldKeys' in copy)
|
|
113
|
+
copy.heldKeys = heldKeys;
|
|
114
|
+
out[key] = copy;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* #213: stamps `PlatformArchonDeps.masterKeys` from the root credentials
|
|
120
|
+
* `bootstrapPlatform` already resolved, onto whatever the host gave the
|
|
121
|
+
* archon step through `deps.executor.archon` -- the values are
|
|
122
|
+
* `credentials`' own, not something the host names separately, so this is
|
|
123
|
+
* not one more thing for `guardStepDeps`'s caller to wire by hand. A run
|
|
124
|
+
* with no `archon` deps at all (the step then reported blocked, as before)
|
|
125
|
+
* is passed through unchanged.
|
|
126
|
+
*/
|
|
127
|
+
function archonMasterKeys(guarded, credentials) {
|
|
128
|
+
if (!guarded.archon)
|
|
129
|
+
return guarded;
|
|
130
|
+
return {
|
|
131
|
+
...guarded,
|
|
132
|
+
archon: {
|
|
133
|
+
...guarded.archon,
|
|
134
|
+
masterKeys: {
|
|
135
|
+
coolifyUrl: credentials.coolifyUrl,
|
|
136
|
+
coolifyToken: credentials.coolifyToken,
|
|
137
|
+
cloudflareParentToken: credentials.cloudflareApiToken,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The platform's first apply, run from Archon's onboarding flow: migrates
|
|
144
|
+
* foundry's tables, declares Company Included and the platform (never
|
|
145
|
+
* overwriting a row already there), then plans and applies round by round
|
|
146
|
+
* until every step is applied or reported blocked. Reads no env and no file:
|
|
147
|
+
* the database, root credentials, held keys and every per-step dependency
|
|
148
|
+
* come in through `deps`.
|
|
149
|
+
*
|
|
150
|
+
* Every root credential, every secret delivered and every held key resolved
|
|
151
|
+
* during the run is redacted from progress events, the result and any error
|
|
152
|
+
* thrown. Running it again applies nothing already applied.
|
|
153
|
+
*/
|
|
154
|
+
export async function bootstrapPlatform(input, deps) {
|
|
155
|
+
const redactor = new Redactor();
|
|
156
|
+
const emit = (event) => {
|
|
157
|
+
deps.onProgress?.({
|
|
158
|
+
...event,
|
|
159
|
+
message: redactor.text(event.message),
|
|
160
|
+
...(event.steps ? { steps: event.steps.map((s) => redactor.text(s)) } : {}),
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
ownerIdentity(input.owner);
|
|
165
|
+
redactor.addDeep(deps.executor);
|
|
166
|
+
const credentials = await deps.rootCredentials();
|
|
167
|
+
redactor.add(credentials.coolifyToken, credentials.cloudflareApiToken, credentials.cloudflareAccountId, ...Object.values(credentials.accessHeaders ?? {}));
|
|
168
|
+
const deliverSecret = async (ref, secret) => {
|
|
169
|
+
redactor.add(secret);
|
|
170
|
+
await deps.deliverSecret(ref, secret);
|
|
171
|
+
};
|
|
172
|
+
const heldKeys = {
|
|
173
|
+
async resolve(ref) {
|
|
174
|
+
const value = await deps.heldKeys.resolve(ref);
|
|
175
|
+
redactor.add(value);
|
|
176
|
+
return value;
|
|
177
|
+
},
|
|
178
|
+
// #213: only present when the host's own heldKeys can put, so a host
|
|
179
|
+
// without one leaves Archon's master keys reported blocked rather
|
|
180
|
+
// than silently no-op'd.
|
|
181
|
+
...(deps.heldKeys.put
|
|
182
|
+
? {
|
|
183
|
+
async put(ref, value) {
|
|
184
|
+
redactor.add(value);
|
|
185
|
+
await deps.heldKeys.put?.(ref, value);
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
: {}),
|
|
189
|
+
};
|
|
190
|
+
const migrated = await migrate(deps.migrateSql);
|
|
191
|
+
emit({
|
|
192
|
+
phase: 'migrate',
|
|
193
|
+
message: migrated.applied.length === 0
|
|
194
|
+
? 'foundry tables already up to date'
|
|
195
|
+
: `migrated: ${migrated.applied.join(', ')}`,
|
|
196
|
+
});
|
|
197
|
+
const orgStore = createPostgresDeclaredOrgStore(deps.db);
|
|
198
|
+
const platformStore = createPostgresPlatformStore(deps.db);
|
|
199
|
+
if (await orgStore.get(COMPANY_INCLUDED_ORG_ID)) {
|
|
200
|
+
emit({ phase: 'declare', message: 'declared org already written' });
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
await orgStore.create(declaredCompanyOrg(input.company, input.owner));
|
|
204
|
+
emit({ phase: 'declare', message: `declared org "${input.company.name}" written` });
|
|
205
|
+
}
|
|
206
|
+
const existing = await platformStore.get();
|
|
207
|
+
if (existing) {
|
|
208
|
+
emit({ phase: 'declare', message: `declared platform already written for ${existing.apex}` });
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
await platformStore.put(declaredPlatformFor(input.company.apex));
|
|
212
|
+
emit({
|
|
213
|
+
phase: 'declare',
|
|
214
|
+
message: `declared platform written for ${input.company.apex}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
let runtime;
|
|
218
|
+
if (deps.runtime) {
|
|
219
|
+
runtime = deps.runtime({ credentials, deliverSecret, heldKeys });
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
const coolify = new CoolifyClient(credentials.coolifyUrl, credentials.coolifyToken, {
|
|
223
|
+
accessHeaders: credentials.accessHeaders,
|
|
224
|
+
});
|
|
225
|
+
const cloudflare = new CloudflareClient(credentials.cloudflareApiToken, credentials.cloudflareAccountId);
|
|
226
|
+
runtime = {
|
|
227
|
+
readers: {
|
|
228
|
+
cloudflare: new ProvidersPlatformCloudflareReader(cloudflare, platformStore),
|
|
229
|
+
coolify: new ProvidersPlatformCoolifyReader(coolify, platformStore),
|
|
230
|
+
},
|
|
231
|
+
executor: new FoundryPlatformStepExecutor(cloudflare, orgStore, {
|
|
232
|
+
coolify,
|
|
233
|
+
deliverSecret,
|
|
234
|
+
...archonMasterKeys(guardStepDeps(deps.executor, redactor, deliverSecret, heldKeys), credentials),
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
// Each step reports when it starts and ends, so a failure names one step.
|
|
239
|
+
let progress = { started: 0, completed: 0 };
|
|
240
|
+
const inner = runtime.executor;
|
|
241
|
+
const executor = {
|
|
242
|
+
execute: async (action, context) => {
|
|
243
|
+
progress.started++;
|
|
244
|
+
const result = await inner.execute(action, context);
|
|
245
|
+
progress.completed++;
|
|
246
|
+
return result;
|
|
247
|
+
},
|
|
248
|
+
...(inner.blockedSteps
|
|
249
|
+
? { blockedSteps: (platform, ids) => inner.blockedSteps?.(platform, ids) ?? {} }
|
|
250
|
+
: {}),
|
|
251
|
+
...(inner.driftedSteps
|
|
252
|
+
? { driftedSteps: (platform, ids) => inner.driftedSteps?.(platform, ids) ?? {} }
|
|
253
|
+
: {}),
|
|
254
|
+
};
|
|
255
|
+
const planApply = new FoundryPlanApply({
|
|
256
|
+
store: orgStore,
|
|
257
|
+
readers: {
|
|
258
|
+
zitadel: unusedOrgReader,
|
|
259
|
+
coolify: unusedOrgReader,
|
|
260
|
+
cloudflare: unusedOrgReader,
|
|
261
|
+
files: unusedOrgReader,
|
|
262
|
+
ai: unusedOrgReader,
|
|
263
|
+
cms: unusedOrgReader,
|
|
264
|
+
},
|
|
265
|
+
executor: unusedOrgExecutor,
|
|
266
|
+
destroyer: unusedOrgExecutor,
|
|
267
|
+
audit: redactingAudit(createPostgresAuditLog(deps.db), redactor),
|
|
268
|
+
authz: allowPlatformApply,
|
|
269
|
+
actor: { id: 'bootstrap-platform', display: 'bootstrapPlatform' },
|
|
270
|
+
platform: { store: platformStore, readers: runtime.readers, executor },
|
|
271
|
+
});
|
|
272
|
+
const driven = await driveApply({
|
|
273
|
+
plan: () => planApply.planPlatform(),
|
|
274
|
+
apply: (plan, stepProgress) => {
|
|
275
|
+
progress = stepProgress;
|
|
276
|
+
return planApply.applyPlatform(plan, 'platform');
|
|
277
|
+
},
|
|
278
|
+
maxRounds: deps.maxRounds,
|
|
279
|
+
onPlan: (round, plan) => emit({
|
|
280
|
+
phase: 'plan',
|
|
281
|
+
round,
|
|
282
|
+
message: `round ${round}: ${plan.steps.length} step(s) planned`,
|
|
283
|
+
steps: plan.steps.map((s) => s.description),
|
|
284
|
+
}),
|
|
285
|
+
onApplied: (round, plan) => emit({
|
|
286
|
+
phase: 'apply',
|
|
287
|
+
round,
|
|
288
|
+
message: `round ${round}: applied`,
|
|
289
|
+
steps: plan.steps.map((s) => s.description),
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
const blocked = (driven.failed ? [] : (driven.lastPlan?.unmanaged ?? []))
|
|
293
|
+
.filter((item) => item.key !== undefined)
|
|
294
|
+
.map((item) => ({
|
|
295
|
+
step: item.key,
|
|
296
|
+
message: redactor.text(item.description),
|
|
297
|
+
}));
|
|
298
|
+
const result = {
|
|
299
|
+
applied: driven.applied.map((s) => redactor.text(s)),
|
|
300
|
+
blocked,
|
|
301
|
+
complete: !driven.failed && blocked.length === 0 && driven.lastPlan?.steps.length === 0,
|
|
302
|
+
...(driven.failed
|
|
303
|
+
? {
|
|
304
|
+
failed: {
|
|
305
|
+
step: redactor.text(driven.failed.step),
|
|
306
|
+
message: redactor.text(driven.failed.message),
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
: {}),
|
|
310
|
+
};
|
|
311
|
+
emit({
|
|
312
|
+
phase: 'done',
|
|
313
|
+
message: driven.failed
|
|
314
|
+
? `stopped: ${result.failed?.message}`
|
|
315
|
+
: `${result.applied.length} step(s) applied, ${blocked.length} blocked`,
|
|
316
|
+
});
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
321
|
+
throw new Error(redactor.text(text));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DeclaredOrg, OrgId, OwnerIdentity } from '../org.js';
|
|
2
|
+
import type { DeclaredPlatform } from '../platform.js';
|
|
3
|
+
/** Company Included's own org id, the same literal `test/fakes.ts`'s `basePlatform()` uses for `ownerOrgId`. */
|
|
4
|
+
export declare const COMPANY_INCLUDED_ORG_ID: OrgId;
|
|
5
|
+
export declare const DEFAULT_GIT_REF = "main";
|
|
6
|
+
/**
|
|
7
|
+
* The Stalwart image tag the platform's mail step runs. `platform.mail.gitRef`
|
|
8
|
+
* is an image tag, not a git ref, and the planner blocks a floating one
|
|
9
|
+
* (`platform-providers.ts`, `mailImageTagBlocked`), so this is a pinned release.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_MAIL_IMAGE_TAG = "v0.16.24";
|
|
12
|
+
export interface BootstrapCompany {
|
|
13
|
+
name: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
apex: string;
|
|
16
|
+
}
|
|
17
|
+
export interface BootstrapOwner {
|
|
18
|
+
givenName: string;
|
|
19
|
+
familyName: string;
|
|
20
|
+
email: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
|
|
24
|
+
* field when one is empty or the email is not an address.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ownerIdentity(owner: BootstrapOwner): OwnerIdentity;
|
|
27
|
+
/**
|
|
28
|
+
* Company Included's declared org: the platform's owner, running no app, mail
|
|
29
|
+
* or chat of its own.
|
|
30
|
+
*/
|
|
31
|
+
export declare function declaredCompanyOrg(company: BootstrapCompany, owner: BootstrapOwner): DeclaredOrg;
|
|
32
|
+
/**
|
|
33
|
+
* The declared platform under `apex`. A disabled service keeps an empty
|
|
34
|
+
* `gitRef`, which `validatePlatform` allows.
|
|
35
|
+
*/
|
|
36
|
+
export declare function declaredPlatformFor(apex: string): DeclaredPlatform;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { validateOwner } from '../org.js';
|
|
2
|
+
import { PLATFORM_SERVICE_KEYS, defaultPlatformHostname } from '../platform.js';
|
|
3
|
+
/** Company Included's own org id, the same literal `test/fakes.ts`'s `basePlatform()` uses for `ownerOrgId`. */
|
|
4
|
+
export const COMPANY_INCLUDED_ORG_ID = 'org_company_included';
|
|
5
|
+
export const DEFAULT_GIT_REF = 'main';
|
|
6
|
+
/**
|
|
7
|
+
* The Stalwart image tag the platform's mail step runs. `platform.mail.gitRef`
|
|
8
|
+
* is an image tag, not a git ref, and the planner blocks a floating one
|
|
9
|
+
* (`platform-providers.ts`, `mailImageTagBlocked`), so this is a pinned release.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MAIL_IMAGE_TAG = 'v0.16.24';
|
|
12
|
+
/**
|
|
13
|
+
* Every platform service except `sfu` is enabled, like the other steps: a
|
|
14
|
+
* service whose dependencies the host did not supply is reported blocked, not
|
|
15
|
+
* planned. `sfu` stays off because it needs a held SSH key and host
|
|
16
|
+
* fingerprint, a separate human step (issue 170).
|
|
17
|
+
*/
|
|
18
|
+
const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
19
|
+
'auth',
|
|
20
|
+
'mail',
|
|
21
|
+
'files',
|
|
22
|
+
'ai',
|
|
23
|
+
'jobs',
|
|
24
|
+
'integrations',
|
|
25
|
+
'archon',
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
|
|
29
|
+
* field when one is empty or the email is not an address.
|
|
30
|
+
*/
|
|
31
|
+
export function ownerIdentity(owner) {
|
|
32
|
+
const identity = {
|
|
33
|
+
email: owner.email?.trim() ?? '',
|
|
34
|
+
givenName: owner.givenName?.trim() ?? '',
|
|
35
|
+
familyName: owner.familyName?.trim() ?? '',
|
|
36
|
+
};
|
|
37
|
+
const problem = validateOwner(identity);
|
|
38
|
+
if (problem)
|
|
39
|
+
throw new Error(`bootstrapPlatform: ${problem}`);
|
|
40
|
+
return identity;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Company Included's declared org: the platform's owner, running no app, mail
|
|
44
|
+
* or chat of its own.
|
|
45
|
+
*/
|
|
46
|
+
export function declaredCompanyOrg(company, owner) {
|
|
47
|
+
return {
|
|
48
|
+
id: COMPANY_INCLUDED_ORG_ID,
|
|
49
|
+
slug: company.slug,
|
|
50
|
+
displayName: company.name,
|
|
51
|
+
domain: null,
|
|
52
|
+
mail: {
|
|
53
|
+
mode: 'off',
|
|
54
|
+
usesDomain: false,
|
|
55
|
+
ownerMailboxAddress: null,
|
|
56
|
+
noReplyLocalPart: 'no-reply',
|
|
57
|
+
},
|
|
58
|
+
chat: { usesDomain: false },
|
|
59
|
+
calls: { enabled: false, usesDomain: false },
|
|
60
|
+
issuer: { usesDomain: false },
|
|
61
|
+
site: { enabled: false, usesDomain: false },
|
|
62
|
+
hosting: {
|
|
63
|
+
enabled: false,
|
|
64
|
+
usesDomain: false,
|
|
65
|
+
database: false,
|
|
66
|
+
tunnel: false,
|
|
67
|
+
gitBranch: DEFAULT_GIT_REF,
|
|
68
|
+
buildPack: 'nixpacks',
|
|
69
|
+
accessEmails: [],
|
|
70
|
+
issuerApps: [],
|
|
71
|
+
},
|
|
72
|
+
sso: { enabled: false },
|
|
73
|
+
guests: { enabled: false },
|
|
74
|
+
solo: { enabled: false },
|
|
75
|
+
storage: { limitBytes: null, limitMicros: null },
|
|
76
|
+
ai: { limitMicros: null },
|
|
77
|
+
buyer: { legalName: null, orgNumber: null, address: null, vatRegistered: null },
|
|
78
|
+
bespokeAppRepo: null,
|
|
79
|
+
adoptedApp: null,
|
|
80
|
+
owner: ownerIdentity(owner),
|
|
81
|
+
test: false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The declared platform under `apex`. A disabled service keeps an empty
|
|
86
|
+
* `gitRef`, which `validatePlatform` allows.
|
|
87
|
+
*/
|
|
88
|
+
export function declaredPlatformFor(apex) {
|
|
89
|
+
const services = {};
|
|
90
|
+
for (const key of PLATFORM_SERVICE_KEYS) {
|
|
91
|
+
const enabled = SERVICES_ENABLED_BY_DEFAULT.has(key);
|
|
92
|
+
services[key] = {
|
|
93
|
+
enabled,
|
|
94
|
+
hostname: defaultPlatformHostname(key, apex),
|
|
95
|
+
gitRef: !enabled ? '' : key === 'mail' ? DEFAULT_MAIL_IMAGE_TAG : DEFAULT_GIT_REF,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { ownerOrgId: COMPANY_INCLUDED_ORG_ID, apex, ...services };
|
|
99
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Plan } from '../plan.js';
|
|
2
|
+
export declare const DEFAULT_MAX_ROUNDS = 20;
|
|
3
|
+
/** How far one round's steps got. */
|
|
4
|
+
export interface StepProgress {
|
|
5
|
+
started: number;
|
|
6
|
+
completed: number;
|
|
7
|
+
}
|
|
8
|
+
export interface DriveApplyOptions {
|
|
9
|
+
plan: () => Promise<Plan>;
|
|
10
|
+
/**
|
|
11
|
+
* Runs one round's plan. It must keep `progress` current: `started` when a
|
|
12
|
+
* step begins, `completed` when it returns. A throw is then attributed to
|
|
13
|
+
* the step that began and did not complete.
|
|
14
|
+
*/
|
|
15
|
+
apply: (plan: Plan, progress: StepProgress) => Promise<unknown>;
|
|
16
|
+
maxRounds?: number;
|
|
17
|
+
/** Called with each round's plan before it is applied. */
|
|
18
|
+
onPlan?: (round: number, plan: Plan) => void;
|
|
19
|
+
/** Called after a round's apply returned. */
|
|
20
|
+
onApplied?: (round: number, plan: Plan) => void;
|
|
21
|
+
}
|
|
22
|
+
export interface DriveApplyResult {
|
|
23
|
+
rounds: number;
|
|
24
|
+
/** Step descriptions applied, in the order they ran. */
|
|
25
|
+
applied: string[];
|
|
26
|
+
/** The last plan computed: empty of steps unless the loop failed or hit the cap. */
|
|
27
|
+
lastPlan: Plan | null;
|
|
28
|
+
/** `step` is the one step that threw, or `apply` when the failure was outside any step. Steps completed before it are in `applied`. */
|
|
29
|
+
failed?: {
|
|
30
|
+
step: string;
|
|
31
|
+
message: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Plans and applies round by round until a plan has no steps (every step
|
|
36
|
+
* applied, or reported blocked in `Plan.unmanaged`), an apply throws, or
|
|
37
|
+
* `maxRounds` is reached. One `applyPlatform` call runs every step of that
|
|
38
|
+
* round's plan in order; `progress` gives the per-step result when one throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function driveApply(options: DriveApplyOptions): Promise<DriveApplyResult>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const DEFAULT_MAX_ROUNDS = 20;
|
|
2
|
+
/**
|
|
3
|
+
* Plans and applies round by round until a plan has no steps (every step
|
|
4
|
+
* applied, or reported blocked in `Plan.unmanaged`), an apply throws, or
|
|
5
|
+
* `maxRounds` is reached. One `applyPlatform` call runs every step of that
|
|
6
|
+
* round's plan in order; `progress` gives the per-step result when one throws.
|
|
7
|
+
*/
|
|
8
|
+
export async function driveApply(options) {
|
|
9
|
+
const maxRounds = options.maxRounds ?? DEFAULT_MAX_ROUNDS;
|
|
10
|
+
const applied = [];
|
|
11
|
+
let lastPlan = null;
|
|
12
|
+
let rounds = 0;
|
|
13
|
+
while (rounds < maxRounds) {
|
|
14
|
+
const plan = await options.plan();
|
|
15
|
+
rounds++;
|
|
16
|
+
lastPlan = plan;
|
|
17
|
+
options.onPlan?.(rounds, plan);
|
|
18
|
+
if (plan.steps.length === 0)
|
|
19
|
+
return { rounds, applied, lastPlan };
|
|
20
|
+
const progress = { started: 0, completed: 0 };
|
|
21
|
+
try {
|
|
22
|
+
await options.apply(plan, progress);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
for (const step of plan.steps.slice(0, progress.completed))
|
|
26
|
+
applied.push(step.description);
|
|
27
|
+
const running = progress.started > progress.completed ? plan.steps[progress.completed] : null;
|
|
28
|
+
return {
|
|
29
|
+
rounds,
|
|
30
|
+
applied,
|
|
31
|
+
lastPlan,
|
|
32
|
+
failed: {
|
|
33
|
+
step: running?.description ?? 'apply',
|
|
34
|
+
message: error instanceof Error ? error.message : String(error),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
for (const step of plan.steps)
|
|
39
|
+
applied.push(step.description);
|
|
40
|
+
options.onApplied?.(rounds, plan);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
rounds,
|
|
44
|
+
applied,
|
|
45
|
+
lastPlan,
|
|
46
|
+
failed: {
|
|
47
|
+
step: 'rounds',
|
|
48
|
+
message: `the platform did not settle after ${maxRounds} rounds; steps still planned: ${lastPlan?.steps.map((s) => s.description).join(', ') ?? ''}`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type { BootstrapCompany, BootstrapOwner, } from './declare.js';
|
|
2
|
+
export type { BootstrapExecutorDeps, BootstrapPlatformDeps, BootstrapPlatformInput, BootstrapProgress, BootstrapResult, RootCredentials, } from './bootstrap.js';
|
|
3
|
+
export { bootstrapPlatform } from './bootstrap.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { bootstrapPlatform } from './bootstrap.js';
|