@wtfalch/foundry 0.27.0 → 0.29.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/calls-sfu.d.ts +6 -0
- package/dist/calls-sfu.js +17 -8
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0050_platform_sfu_connection_revision.sql +7 -0
- package/dist/plan/boule.d.ts +27 -3
- package/dist/plan/boule.js +39 -1
- package/dist/plan/diff.js +49 -6
- package/dist/plan/executor.d.ts +46 -7
- package/dist/plan/executor.js +169 -26
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +2 -2
- package/dist/plan/live.d.ts +3 -3
- package/dist/plan/plan-apply.js +4 -0
- package/dist/plan/platform-diff.d.ts +10 -0
- package/dist/plan/platform-diff.js +29 -14
- package/dist/plan/platform-org.d.ts +3 -4
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +16 -9
- package/dist/plan/platform-sfu.d.ts +10 -0
- package/dist/plan/platform-sfu.js +39 -0
- package/dist/plan/providers-readers.d.ts +1 -1
- package/dist/plan/providers-readers.js +19 -4
- package/dist/platform.d.ts +11 -1
- package/dist/platform.js +6 -0
- package/dist/store/postgres.js +7 -1
- package/dist/store/schema.d.ts +17 -0
- package/dist/store/schema.js +2 -0
- package/package.json +1 -1
|
@@ -35,9 +35,48 @@
|
|
|
35
35
|
* live outside this package. `calls-sfu-ssh-adapter.ts`'s {@link CallsSfuSshSession} is the narrow shape
|
|
36
36
|
* a test fakes instead.
|
|
37
37
|
*/
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
38
39
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
39
40
|
import { mintLivekitCredentials } from '../calls-sfu-keys.js';
|
|
40
41
|
import { applyCallsSfuOverSession } from '../calls-sfu-ssh-adapter.js';
|
|
42
|
+
import { CALLS_SFU_FIREWALL_COMMANDS, CALLS_SFU_SSH_RULE_COMMAND, CALLS_SFU_UP_COMMAND, cloudflaredEnvFile, dockerComposeYaml, livekitConfigYaml, } from '../calls-sfu.js';
|
|
43
|
+
/**
|
|
44
|
+
* Hash of what `applyCallsSfu` writes and runs, rendered from placeholder
|
|
45
|
+
* values so no secret is involved: a change to the compose file, the
|
|
46
|
+
* `livekit.yaml` template, the env file template or a command changes it.
|
|
47
|
+
*/
|
|
48
|
+
function callsSfuFilesVersion() {
|
|
49
|
+
const placeholder = {
|
|
50
|
+
address: 'address',
|
|
51
|
+
livekitKeys: { key: 'secret' },
|
|
52
|
+
cloudflaredTunnelToken: 'token',
|
|
53
|
+
};
|
|
54
|
+
return createHash('sha256')
|
|
55
|
+
.update(JSON.stringify([
|
|
56
|
+
dockerComposeYaml(),
|
|
57
|
+
livekitConfigYaml(placeholder),
|
|
58
|
+
cloudflaredEnvFile(placeholder.cloudflaredTunnelToken),
|
|
59
|
+
CALLS_SFU_UP_COMMAND,
|
|
60
|
+
CALLS_SFU_SSH_RULE_COMMAND,
|
|
61
|
+
CALLS_SFU_FIREWALL_COMMANDS,
|
|
62
|
+
]))
|
|
63
|
+
.digest('hex');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* What the last successful `sfu` apply is recorded as
|
|
67
|
+
* (`PLATFORM_SFU_APPLIED_SCOPE_KEY`): a SHA-256 hex digest of foundry's calls
|
|
68
|
+
* files, the sorted distinct org slugs and the connection revision. It holds
|
|
69
|
+
* no secret and does not depend on slug order or duplicates.
|
|
70
|
+
*/
|
|
71
|
+
export function callsSfuAppliedFingerprint(input) {
|
|
72
|
+
return createHash('sha256')
|
|
73
|
+
.update(JSON.stringify([
|
|
74
|
+
callsSfuFilesVersion(),
|
|
75
|
+
[...new Set(input.orgSlugs)].sort(),
|
|
76
|
+
input.connectionRevision ?? '',
|
|
77
|
+
]))
|
|
78
|
+
.digest('hex');
|
|
79
|
+
}
|
|
41
80
|
/**
|
|
42
81
|
* The key id the platform's own LiveKit pair is held under, alongside every
|
|
43
82
|
* org's own pair, in the same {@link CallsSfuKeysPort} (#152 round 2 review:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
2
2
|
import type { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
3
3
|
import { type ZitadelClient } from '@wtfalch/providers/zitadel';
|
|
4
|
-
import type
|
|
4
|
+
import { type OrgId } from '../org.js';
|
|
5
5
|
import type { DeclaredOrgStore } from '../store.js';
|
|
6
6
|
import type { AiReader, CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
7
7
|
/** The `CoolifyClient` methods the readers call, so a host's own implementation needs no cast. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ZitadelError } from '@wtfalch/providers/zitadel';
|
|
2
|
-
import {
|
|
2
|
+
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
|
+
import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
3
4
|
/**
|
|
4
5
|
* A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
|
|
5
6
|
* — the one place foundry records a provider's own id for an org today,
|
|
@@ -247,7 +248,7 @@ async function withAgoraEnvConfig(client, resource, recorded) {
|
|
|
247
248
|
* `executor.ts` would write. Gated and failing exactly like
|
|
248
249
|
* {@link withAgoraEnvConfig}.
|
|
249
250
|
*/
|
|
250
|
-
async function withBouleEnvConfig(client, resource, recorded) {
|
|
251
|
+
async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
|
|
251
252
|
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('boule:')) {
|
|
252
253
|
return resource;
|
|
253
254
|
}
|
|
@@ -257,7 +258,20 @@ async function withBouleEnvConfig(client, resource, recorded) {
|
|
|
257
258
|
if (!e.is_preview && managed.includes(e.key))
|
|
258
259
|
env[e.key] = e.value;
|
|
259
260
|
}
|
|
260
|
-
|
|
261
|
+
// The hosted app's Lettermint token, as `config.appMailToken`: the one
|
|
262
|
+
// Boule must hold too. In memory only, compared by `planBoule` and never
|
|
263
|
+
// written to a step, description or log.
|
|
264
|
+
const orgId = recorded[resource.key]?.slice('boule:'.length);
|
|
265
|
+
const appUuid = Object.entries(recorded).find(([, value]) => value === `app:${orgId}:${APP_ENTRY_KEY}`)?.[0];
|
|
266
|
+
// A recorded app that is not in the live listing is gone: no app token, and
|
|
267
|
+
// no read of its envs. A listed app whose read fails fails like any read.
|
|
268
|
+
const appToken = appUuid && liveKeys.has(appUuid)
|
|
269
|
+
? (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value
|
|
270
|
+
: undefined;
|
|
271
|
+
return {
|
|
272
|
+
...resource,
|
|
273
|
+
config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
|
|
274
|
+
};
|
|
261
275
|
}
|
|
262
276
|
/** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
|
|
263
277
|
function toFoundryResources(system, resources) {
|
|
@@ -290,7 +304,8 @@ export class ProvidersCoolifyReader {
|
|
|
290
304
|
const withEnvNames = await Promise.all(withHealthCheck.map((r) => withEnvNamesConfig(this.client, r, recorded)));
|
|
291
305
|
const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
|
|
292
306
|
const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
|
|
293
|
-
const
|
|
307
|
+
const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
|
|
308
|
+
const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
|
|
294
309
|
return applyRecordedIds(withBouleConfig, recorded);
|
|
295
310
|
}
|
|
296
311
|
/** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
|
package/dist/platform.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ export interface PlatformService {
|
|
|
12
12
|
hostname: string;
|
|
13
13
|
gitRef: string;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* `sfu`'s own extra field. A host that changes what the SFU VM is reached
|
|
17
|
+
* with (its SSH key, host key or tunnel token) sets a new `connectionRevision`
|
|
18
|
+
* so the platform plan shows the `sfu` step again; foundry never reads it.
|
|
19
|
+
*/
|
|
20
|
+
export interface PlatformSfuService extends PlatformService {
|
|
21
|
+
connectionRevision?: string;
|
|
22
|
+
}
|
|
15
23
|
/**
|
|
16
24
|
* #491: `mail`'s own extra field, on top of every other service's shape.
|
|
17
25
|
* Company Included (or whichever org's mail domain is the platform's own
|
|
@@ -76,7 +84,7 @@ export interface DeclaredPlatform {
|
|
|
76
84
|
serverIpv4?: string | null;
|
|
77
85
|
auth: PlatformService;
|
|
78
86
|
mail: PlatformMailService;
|
|
79
|
-
sfu:
|
|
87
|
+
sfu: PlatformSfuService;
|
|
80
88
|
files: PlatformService;
|
|
81
89
|
ai: PlatformService;
|
|
82
90
|
jobs: PlatformService;
|
|
@@ -116,6 +124,8 @@ export declare function defaultPlatformHostname(service: PlatformServiceKey, ape
|
|
|
116
124
|
* - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
|
|
117
125
|
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
118
126
|
* platform service does not live outside the platform's own apex.
|
|
127
|
+
* - `sfu.connectionRevision`, when declared, is at most 64 characters of
|
|
128
|
+
* `A-Za-z0-9:.-`.
|
|
119
129
|
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|
|
120
130
|
* an enabled service with nothing to deploy is not yet declared.
|
|
121
131
|
*/
|
package/dist/platform.js
CHANGED
|
@@ -32,6 +32,8 @@ function serviceOf(platform, key) {
|
|
|
32
32
|
* - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
|
|
33
33
|
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
34
34
|
* platform service does not live outside the platform's own apex.
|
|
35
|
+
* - `sfu.connectionRevision`, when declared, is at most 64 characters of
|
|
36
|
+
* `A-Za-z0-9:.-`.
|
|
35
37
|
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|
|
36
38
|
* an enabled service with nothing to deploy is not yet declared.
|
|
37
39
|
*/
|
|
@@ -54,6 +56,10 @@ export function validatePlatform(platform) {
|
|
|
54
56
|
return `${key}.gitRef must not be empty once ${key}.enabled is true`;
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
const revision = platform.sfu.connectionRevision;
|
|
60
|
+
if (revision != null && !/^[A-Za-z0-9:.-]{0,64}$/.test(revision)) {
|
|
61
|
+
return 'sfu.connectionRevision must be at most 64 characters of A-Z, a-z, 0-9, ":", "." and "-"';
|
|
62
|
+
}
|
|
57
63
|
const { uptime } = platform;
|
|
58
64
|
if (Boolean(uptime.tag.trim()) !== Boolean(uptime.sha256.trim())) {
|
|
59
65
|
return 'uptime.tag and uptime.sha256 must be set together: a pin is both';
|
package/dist/store/postgres.js
CHANGED
|
@@ -606,7 +606,12 @@ function rowToPlatform(row) {
|
|
|
606
606
|
gitRef: row.mailGitRef,
|
|
607
607
|
...(row.mailLettermintProjectId ? { lettermintProjectId: row.mailLettermintProjectId } : {}),
|
|
608
608
|
},
|
|
609
|
-
sfu: {
|
|
609
|
+
sfu: {
|
|
610
|
+
enabled: row.sfuEnabled,
|
|
611
|
+
hostname: row.sfuHostname,
|
|
612
|
+
gitRef: row.sfuGitRef,
|
|
613
|
+
...(row.sfuConnectionRevision ? { connectionRevision: row.sfuConnectionRevision } : {}),
|
|
614
|
+
},
|
|
610
615
|
files: { enabled: row.filesEnabled, hostname: row.filesHostname, gitRef: row.filesGitRef },
|
|
611
616
|
ai: { enabled: row.aiEnabled, hostname: row.aiHostname, gitRef: row.aiGitRef },
|
|
612
617
|
jobs: { enabled: row.jobsEnabled, hostname: row.jobsHostname, gitRef: row.jobsGitRef },
|
|
@@ -654,6 +659,7 @@ function platformToRow(platform) {
|
|
|
654
659
|
sfuEnabled: platform.sfu.enabled,
|
|
655
660
|
sfuHostname: platform.sfu.hostname,
|
|
656
661
|
sfuGitRef: platform.sfu.gitRef,
|
|
662
|
+
sfuConnectionRevision: platform.sfu.connectionRevision ?? null,
|
|
657
663
|
filesEnabled: platform.files.enabled,
|
|
658
664
|
filesHostname: platform.files.hostname,
|
|
659
665
|
filesGitRef: platform.files.gitRef,
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -1643,6 +1643,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
1643
1643
|
identity: undefined;
|
|
1644
1644
|
generated: undefined;
|
|
1645
1645
|
}, {}, {}>;
|
|
1646
|
+
sfuConnectionRevision: import("drizzle-orm/pg-core").PgColumn<{
|
|
1647
|
+
name: "sfu_connection_revision";
|
|
1648
|
+
tableName: "foundry_declared_platform";
|
|
1649
|
+
dataType: "string";
|
|
1650
|
+
columnType: "PgText";
|
|
1651
|
+
data: string;
|
|
1652
|
+
driverParam: string;
|
|
1653
|
+
notNull: false;
|
|
1654
|
+
hasDefault: false;
|
|
1655
|
+
isPrimaryKey: false;
|
|
1656
|
+
isAutoincrement: false;
|
|
1657
|
+
hasRuntimeDefault: false;
|
|
1658
|
+
enumValues: [string, ...string[]];
|
|
1659
|
+
baseColumn: never;
|
|
1660
|
+
identity: undefined;
|
|
1661
|
+
generated: undefined;
|
|
1662
|
+
}, {}, {}>;
|
|
1646
1663
|
filesEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
1647
1664
|
name: "files_enabled";
|
|
1648
1665
|
tableName: "foundry_declared_platform";
|
package/dist/store/schema.js
CHANGED
|
@@ -165,6 +165,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
|
|
|
165
165
|
sfuEnabled: boolean('sfu_enabled').notNull().default(false),
|
|
166
166
|
sfuHostname: text('sfu_hostname').notNull(),
|
|
167
167
|
sfuGitRef: text('sfu_git_ref').notNull().default(''),
|
|
168
|
+
/** `migrations/0050_platform_sfu_connection_revision.sql`. Declared; null means unset. */
|
|
169
|
+
sfuConnectionRevision: text('sfu_connection_revision'),
|
|
168
170
|
filesEnabled: boolean('files_enabled').notNull().default(false),
|
|
169
171
|
filesHostname: text('files_hostname').notNull(),
|
|
170
172
|
filesGitRef: text('files_git_ref').notNull().default(''),
|