@wtfalch/foundry 0.4.0 → 0.5.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 +96 -0
- package/dist/bootstrap/bootstrap.js +285 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +94 -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 +9 -6
- package/dist/calls-sfu-deploy-support.js +9 -6
- 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 +5 -4
- package/dist/calls-sfu.js +5 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- 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/org.js +8 -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 +631 -116
- package/dist/plan/executor.d.ts +475 -168
- package/dist/plan/executor.js +543 -175
- package/dist/plan/index.d.ts +22 -5
- package/dist/plan/index.js +12 -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/plan-apply.d.ts +192 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +312 -0
- package/dist/plan/platform-diff.js +867 -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 +120 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +501 -0
- package/dist/plan/platform-providers.js +3029 -0
- package/dist/plan/platform-sfu.d.ts +93 -0
- package/dist/plan/platform-sfu.js +100 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +34 -0
- package/dist/plan/zitadel-machine-user.js +95 -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 +108 -0
- package/dist/requested-changes.js +95 -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 +172 -2
- package/dist/store/schema.d.ts +683 -0
- package/dist/store/schema.js +57 -0
- package/dist/store.d.ts +46 -0
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
4
|
+
* need to agree on for an org's Agora -- the hostname, the values the host
|
|
5
|
+
* supplies, and the env vars those produce -- so the plan can compare the
|
|
6
|
+
* live app with exactly what the executor would write.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* `AppliedState.serviceIds` scope keys `desiredAgoraEnv` reads:
|
|
10
|
+
* `zitadel_org` is `executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`
|
|
11
|
+
* scope id, `agora_client_id` the fixed `agora` ZITADEL client's `clientId`
|
|
12
|
+
* (recorded by `executor.ts`'s `zitadelApp`).
|
|
13
|
+
*/
|
|
14
|
+
export declare const ZITADEL_ORG_SCOPE_KEY = "zitadel_org";
|
|
15
|
+
export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
|
|
16
|
+
/**
|
|
17
|
+
* The accent every org's Agora wears until orgs choose their own: the house
|
|
18
|
+
* theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
|
|
19
|
+
* `design/packages/house/src/themes.ts`, the default theme
|
|
20
|
+
* `HOUSE_DEFAULT_THEME`). Agora's config parser requires `theme.accent` as a
|
|
21
|
+
* hex colour (`agora/apps/agora/src/agoras.ts`). Restated here rather than
|
|
22
|
+
* imported: `packages/foundry` carries no `@wtfalch/house` dependency.
|
|
23
|
+
*/
|
|
24
|
+
export declare const AGORA_THEME_ACCENT = "#B23A0C";
|
|
25
|
+
/**
|
|
26
|
+
* Provider-side values only the host can supply, like every other provider
|
|
27
|
+
* value `FoundryStepExecutorPorts` carries. As of #177, `plan-apply.ts`'s
|
|
28
|
+
* `FoundryPlanApply` derives these itself from the applied platform
|
|
29
|
+
* (`platform-org.ts`'s `agoraHostValuesFromPlatform`) whenever a host wires
|
|
30
|
+
* one in and supplies no `AgoraHostPort` of its own -- an explicit `agora`
|
|
31
|
+
* port still overrides that, unchanged. `null` means the value is not
|
|
32
|
+
* available yet. The first three are required by Agora's config parser
|
|
33
|
+
* (`agoras.ts`: `issuer`, `mailProjectId`, `messagingProjectId`);
|
|
34
|
+
* `mailUpstream`/`filesUpstream` are the mail/files servers' internal
|
|
35
|
+
* addresses, optional -- unset, `AGORA_MAIL_UPSTREAM`/`AGORA_FILES_UPSTREAM`
|
|
36
|
+
* are not pushed and Agora answers 502 on those routes rather than serving
|
|
37
|
+
* anything wrong (`executor.ts`'s `coolifyAgora` doc comment).
|
|
38
|
+
*/
|
|
39
|
+
export interface AgoraHostValues {
|
|
40
|
+
issuer: string | null;
|
|
41
|
+
mailProjectId: string | null;
|
|
42
|
+
messagingProjectId: string | null;
|
|
43
|
+
mailUpstream: string | null;
|
|
44
|
+
filesUpstream: string | null;
|
|
45
|
+
}
|
|
46
|
+
export interface AgoraHostPort {
|
|
47
|
+
valuesFor(orgId: OrgId): Promise<AgoraHostValues>;
|
|
48
|
+
}
|
|
49
|
+
/** No host values at all: what an absent port means. */
|
|
50
|
+
export declare const NO_AGORA_HOST_VALUES: AgoraHostValues;
|
|
51
|
+
/** What the plan reads from the host: its values, or why it could not get them. */
|
|
52
|
+
export type AgoraHostReading = AgoraHostValues | {
|
|
53
|
+
error: string;
|
|
54
|
+
};
|
|
55
|
+
/** Reads the port once for a plan. An absent port, or one that throws, is a blocked Agora, never a failed plan. */
|
|
56
|
+
export declare function readAgoraHost(port: AgoraHostPort | undefined, orgId: OrgId): Promise<AgoraHostReading>;
|
|
57
|
+
/** The host values Agora cannot be configured without, by name -- the ones `host` lacks. */
|
|
58
|
+
export declare function agoraHostMissing(host: AgoraHostValues): string[];
|
|
59
|
+
/**
|
|
60
|
+
* `agora.<domain>` when the org has a domain, `<slug>-agora.oikos.host`
|
|
61
|
+
* otherwise (the 2026-09-24 layout, foundry#14; `../slug.ts`'s
|
|
62
|
+
* `AGORA_SUFFIX` reserves the collision).
|
|
63
|
+
*/
|
|
64
|
+
export declare function agoraHostnameFor(org: DeclaredOrg): string;
|
|
65
|
+
/**
|
|
66
|
+
* The env vars an org's Agora app should carry, and every input still
|
|
67
|
+
* missing to build them. `AGORA_CONFIG` is present only when `missing` is
|
|
68
|
+
* empty; callers that write must refuse on a non-empty `missing`.
|
|
69
|
+
* `AGORA_MAIL_UPSTREAM` is present only when mail is on and the host supplies
|
|
70
|
+
* an address.
|
|
71
|
+
*/
|
|
72
|
+
export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, host: AgoraHostValues): {
|
|
73
|
+
env: Record<string, string>;
|
|
74
|
+
missing: string[];
|
|
75
|
+
};
|
|
76
|
+
/** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
|
|
77
|
+
export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
3
|
+
* need to agree on for an org's Agora -- the hostname, the values the host
|
|
4
|
+
* supplies, and the env vars those produce -- so the plan can compare the
|
|
5
|
+
* live app with exactly what the executor would write.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* `AppliedState.serviceIds` scope keys `desiredAgoraEnv` reads:
|
|
9
|
+
* `zitadel_org` is `executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`
|
|
10
|
+
* scope id, `agora_client_id` the fixed `agora` ZITADEL client's `clientId`
|
|
11
|
+
* (recorded by `executor.ts`'s `zitadelApp`).
|
|
12
|
+
*/
|
|
13
|
+
export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
14
|
+
export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
|
|
15
|
+
/**
|
|
16
|
+
* The accent every org's Agora wears until orgs choose their own: the house
|
|
17
|
+
* theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
|
|
18
|
+
* `design/packages/house/src/themes.ts`, the default theme
|
|
19
|
+
* `HOUSE_DEFAULT_THEME`). Agora's config parser requires `theme.accent` as a
|
|
20
|
+
* hex colour (`agora/apps/agora/src/agoras.ts`). Restated here rather than
|
|
21
|
+
* imported: `packages/foundry` carries no `@wtfalch/house` dependency.
|
|
22
|
+
*/
|
|
23
|
+
export const AGORA_THEME_ACCENT = '#B23A0C';
|
|
24
|
+
/** No host values at all: what an absent port means. */
|
|
25
|
+
export const NO_AGORA_HOST_VALUES = {
|
|
26
|
+
issuer: null,
|
|
27
|
+
mailProjectId: null,
|
|
28
|
+
messagingProjectId: null,
|
|
29
|
+
mailUpstream: null,
|
|
30
|
+
filesUpstream: null,
|
|
31
|
+
};
|
|
32
|
+
/** Reads the port once for a plan. An absent port, or one that throws, is a blocked Agora, never a failed plan. */
|
|
33
|
+
export async function readAgoraHost(port, orgId) {
|
|
34
|
+
if (!port)
|
|
35
|
+
return NO_AGORA_HOST_VALUES;
|
|
36
|
+
try {
|
|
37
|
+
return await port.valuesFor(orgId);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** The host values Agora cannot be configured without, by name -- the ones `host` lacks. */
|
|
44
|
+
export function agoraHostMissing(host) {
|
|
45
|
+
const missing = [];
|
|
46
|
+
if (!host.issuer)
|
|
47
|
+
missing.push('issuer');
|
|
48
|
+
if (!host.mailProjectId)
|
|
49
|
+
missing.push('mailProjectId');
|
|
50
|
+
if (!host.messagingProjectId)
|
|
51
|
+
missing.push('messagingProjectId');
|
|
52
|
+
return missing;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `agora.<domain>` when the org has a domain, `<slug>-agora.oikos.host`
|
|
56
|
+
* otherwise (the 2026-09-24 layout, foundry#14; `../slug.ts`'s
|
|
57
|
+
* `AGORA_SUFFIX` reserves the collision).
|
|
58
|
+
*/
|
|
59
|
+
export function agoraHostnameFor(org) {
|
|
60
|
+
return org.domain ? `agora.${org.domain}` : `${org.slug}-agora.oikos.host`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The env vars an org's Agora app should carry, and every input still
|
|
64
|
+
* missing to build them. `AGORA_CONFIG` is present only when `missing` is
|
|
65
|
+
* empty; callers that write must refuse on a non-empty `missing`.
|
|
66
|
+
* `AGORA_MAIL_UPSTREAM` is present only when mail is on and the host supplies
|
|
67
|
+
* an address.
|
|
68
|
+
*/
|
|
69
|
+
export function desiredAgoraEnv(org, recorded, host) {
|
|
70
|
+
const env = { AGORA_CANONICAL_HOST: agoraHostnameFor(org) };
|
|
71
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
72
|
+
const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
|
|
73
|
+
const missing = [];
|
|
74
|
+
if (!zitadelOrgId)
|
|
75
|
+
missing.push('zitadelOrgId');
|
|
76
|
+
if (!clientId)
|
|
77
|
+
missing.push('clientId');
|
|
78
|
+
missing.push(...agoraHostMissing(host));
|
|
79
|
+
if (missing.length === 0) {
|
|
80
|
+
env.AGORA_CONFIG = JSON.stringify({
|
|
81
|
+
name: org.displayName,
|
|
82
|
+
theme: { accent: AGORA_THEME_ACCENT },
|
|
83
|
+
issuer: host.issuer,
|
|
84
|
+
clientId,
|
|
85
|
+
organisationId: zitadelOrgId,
|
|
86
|
+
mailProjectId: host.mailProjectId,
|
|
87
|
+
messagingProjectId: host.messagingProjectId,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (org.mail.mode !== 'off' && host.mailUpstream) {
|
|
91
|
+
env.AGORA_MAIL_UPSTREAM = host.mailUpstream;
|
|
92
|
+
}
|
|
93
|
+
// Files is always on for every org (#80), unlike mail -- no mode to gate
|
|
94
|
+
// this on, only whether the host supplies an address at all (#177).
|
|
95
|
+
if (host.filesUpstream) {
|
|
96
|
+
env.AGORA_FILES_UPSTREAM = host.filesUpstream;
|
|
97
|
+
}
|
|
98
|
+
return { env, missing };
|
|
99
|
+
}
|
|
100
|
+
function stable(value) {
|
|
101
|
+
if (Array.isArray(value))
|
|
102
|
+
return `[${value.map(stable).join(',')}]`;
|
|
103
|
+
if (value && typeof value === 'object') {
|
|
104
|
+
const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1);
|
|
105
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(',')}}`;
|
|
106
|
+
}
|
|
107
|
+
return JSON.stringify(value);
|
|
108
|
+
}
|
|
109
|
+
function sameEnvValue(key, desired, live) {
|
|
110
|
+
if (key !== 'AGORA_CONFIG')
|
|
111
|
+
return desired === live;
|
|
112
|
+
try {
|
|
113
|
+
return stable(JSON.parse(desired)) === stable(JSON.parse(live));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
|
|
120
|
+
export function agoraEnvDrift(desired, live) {
|
|
121
|
+
return Object.entries(desired)
|
|
122
|
+
.filter(([key, value]) => {
|
|
123
|
+
const liveValue = live[key];
|
|
124
|
+
return liveValue === undefined || !sameEnvValue(key, value, liveValue);
|
|
125
|
+
})
|
|
126
|
+
.map(([key]) => key);
|
|
127
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CloudflarePort, CoolifyPort } from './executor.js';
|
|
2
|
+
/**
|
|
3
|
+
* #197: the pinned `cloudflared` image, the same release tag as
|
|
4
|
+
* `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
|
|
5
|
+
* `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
|
|
8
|
+
/**
|
|
9
|
+
* The inline Dockerfile a docker-image `cloudflared` app needs, copied from
|
|
10
|
+
* `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
|
|
11
|
+
* image's own `CMD` is `version`, and Coolify's `start_command` is ignored
|
|
12
|
+
* for image-based applications, so the run command has to be stated in a
|
|
13
|
+
* Dockerfile's `CMD` instead. `TUNNEL_TOKEN` is never baked in here; it
|
|
14
|
+
* reaches the container only via `bulkUpsertEnvs`, as an environment
|
|
15
|
+
* variable `cloudflared` reads at boot.
|
|
16
|
+
*/
|
|
17
|
+
export declare function cloudflaredDockerfile(): string;
|
|
18
|
+
/**
|
|
19
|
+
* #197: ensure one `cloudflared` connector app exists in Coolify for a
|
|
20
|
+
* tunnel, with the current token, deployed if needed. Decision: one connector
|
|
21
|
+
* per platform tunnel, so each tunnel-owning platform step (auth, mail,
|
|
22
|
+
* archon) calls this once with its own app name and scope key.
|
|
23
|
+
*/
|
|
24
|
+
export interface EnsureCloudflaredConnectorInput {
|
|
25
|
+
coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
|
|
26
|
+
cloudflare: Pick<CloudflarePort, 'getTunnelToken'>;
|
|
27
|
+
/** The Cloudflare tunnel this connector dials into (already created and configured by the caller). */
|
|
28
|
+
tunnelId: string;
|
|
29
|
+
/** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
|
|
30
|
+
appName: string;
|
|
31
|
+
projectUuid: string;
|
|
32
|
+
environment: {
|
|
33
|
+
id: number;
|
|
34
|
+
name: string;
|
|
35
|
+
uuid: string;
|
|
36
|
+
};
|
|
37
|
+
/** Called only when the app must be created -- never on the found-by-name or found-by-id paths. */
|
|
38
|
+
resolveServer: () => Promise<{
|
|
39
|
+
uuid: string;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* The app's uuid from a prior run's recorded scope id, or `null`. When
|
|
43
|
+
* given, it is trusted over a by-name lookup, so a renamed or
|
|
44
|
+
* duplicate-named app never causes a second create. A recorded uuid whose
|
|
45
|
+
* app no longer exists (not found) falls through to find-by-name, then create.
|
|
46
|
+
*/
|
|
47
|
+
recordedConnectorAppId: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Called with the app's uuid as soon as it is created or found by name,
|
|
50
|
+
* before the token fetch, env write and deploy. The caller records and
|
|
51
|
+
* persists it there, so a later failure never leaves an app nothing points at.
|
|
52
|
+
*/
|
|
53
|
+
recordAppId: (appId: string) => Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Finds or creates the one Coolify app that runs `cloudflared` for
|
|
57
|
+
* {@link EnsureCloudflaredConnectorInput.tunnelId}, writes its tunnel token as
|
|
58
|
+
* `TUNNEL_TOKEN` (a literal env var) when the token is new or changed, and
|
|
59
|
+
* deploys it when it was just created, its token changed, or it is not
|
|
60
|
+
* explicitly running (a missing status counts as not running, so a run that
|
|
61
|
+
* failed after the env write is repaired by the next one). `CLAUDE.md` rule 4.
|
|
62
|
+
*
|
|
63
|
+
* The token is a credential (`CLAUDE.md` rule 6): it goes to `bulkUpsertEnvs`
|
|
64
|
+
* and nowhere else. A failed write throws an error that names the app only,
|
|
65
|
+
* with no cause, because a Coolify error can echo the request body.
|
|
66
|
+
*/
|
|
67
|
+
export declare function ensureCloudflaredConnector(input: EnsureCloudflaredConnectorInput): Promise<{
|
|
68
|
+
appId: string;
|
|
69
|
+
deployed: boolean;
|
|
70
|
+
}>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { isNotFoundError } from './executor.js';
|
|
2
|
+
/**
|
|
3
|
+
* #197: the pinned `cloudflared` image, the same release tag as
|
|
4
|
+
* `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
|
|
5
|
+
* `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
|
|
6
|
+
*/
|
|
7
|
+
export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
|
|
8
|
+
/**
|
|
9
|
+
* The inline Dockerfile a docker-image `cloudflared` app needs, copied from
|
|
10
|
+
* `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
|
|
11
|
+
* image's own `CMD` is `version`, and Coolify's `start_command` is ignored
|
|
12
|
+
* for image-based applications, so the run command has to be stated in a
|
|
13
|
+
* Dockerfile's `CMD` instead. `TUNNEL_TOKEN` is never baked in here; it
|
|
14
|
+
* reaches the container only via `bulkUpsertEnvs`, as an environment
|
|
15
|
+
* variable `cloudflared` reads at boot.
|
|
16
|
+
*/
|
|
17
|
+
export function cloudflaredDockerfile() {
|
|
18
|
+
return `FROM ${CLOUDFLARED_IMAGE_REF}\nCMD ["tunnel", "--no-autoupdate", "run"]\n`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Finds or creates the one Coolify app that runs `cloudflared` for
|
|
22
|
+
* {@link EnsureCloudflaredConnectorInput.tunnelId}, writes its tunnel token as
|
|
23
|
+
* `TUNNEL_TOKEN` (a literal env var) when the token is new or changed, and
|
|
24
|
+
* deploys it when it was just created, its token changed, or it is not
|
|
25
|
+
* explicitly running (a missing status counts as not running, so a run that
|
|
26
|
+
* failed after the env write is repaired by the next one). `CLAUDE.md` rule 4.
|
|
27
|
+
*
|
|
28
|
+
* The token is a credential (`CLAUDE.md` rule 6): it goes to `bulkUpsertEnvs`
|
|
29
|
+
* and nowhere else. A failed write throws an error that names the app only,
|
|
30
|
+
* with no cause, because a Coolify error can echo the request body.
|
|
31
|
+
*/
|
|
32
|
+
export async function ensureCloudflaredConnector(input) {
|
|
33
|
+
const { coolify, cloudflare } = input;
|
|
34
|
+
const liveStatus = async (uuid) => {
|
|
35
|
+
try {
|
|
36
|
+
return { found: true, status: (await coolify.getApplication(uuid)).status };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (isNotFoundError(error))
|
|
40
|
+
return { found: false };
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
let appId = null;
|
|
45
|
+
let status;
|
|
46
|
+
let justCreated = false;
|
|
47
|
+
if (input.recordedConnectorAppId) {
|
|
48
|
+
const live = await liveStatus(input.recordedConnectorAppId);
|
|
49
|
+
if (live.found) {
|
|
50
|
+
appId = input.recordedConnectorAppId;
|
|
51
|
+
status = live.status;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!appId) {
|
|
55
|
+
const found = await coolify.findApplicationByName(input.appName, {
|
|
56
|
+
environmentIds: [input.environment.id],
|
|
57
|
+
});
|
|
58
|
+
if (found) {
|
|
59
|
+
const live = await liveStatus(found.uuid);
|
|
60
|
+
if (live.found) {
|
|
61
|
+
appId = found.uuid;
|
|
62
|
+
status = live.status;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!appId) {
|
|
67
|
+
const server = await input.resolveServer();
|
|
68
|
+
const created = await coolify.createDockerfileApplication({
|
|
69
|
+
project_uuid: input.projectUuid,
|
|
70
|
+
server_uuid: server.uuid,
|
|
71
|
+
environment_name: input.environment.name,
|
|
72
|
+
environment_uuid: input.environment.uuid,
|
|
73
|
+
dockerfile: cloudflaredDockerfile(),
|
|
74
|
+
name: input.appName,
|
|
75
|
+
description: 'cloudflared connector for this platform tunnel (#197)',
|
|
76
|
+
instant_deploy: false,
|
|
77
|
+
});
|
|
78
|
+
appId = created.uuid;
|
|
79
|
+
justCreated = true;
|
|
80
|
+
}
|
|
81
|
+
if (appId !== input.recordedConnectorAppId)
|
|
82
|
+
await input.recordAppId(appId);
|
|
83
|
+
const token = await cloudflare.getTunnelToken(input.tunnelId);
|
|
84
|
+
const tokenChanged = justCreated || (await tunnelTokenDiffers(coolify, appId, token));
|
|
85
|
+
if (tokenChanged) {
|
|
86
|
+
try {
|
|
87
|
+
await coolify.bulkUpsertEnvs(appId, [{ key: 'TUNNEL_TOKEN', value: token, literal: true }]);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Error(`cloudflared connector ${input.appName}: writing TUNNEL_TOKEN failed`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Only a connector that is up or on its way up is left alone; a missing,
|
|
94
|
+
// stopped, crash-looping or degraded one is deployed.
|
|
95
|
+
const upOrStarting = Boolean(status?.startsWith('running') || status?.startsWith('starting'));
|
|
96
|
+
const deployed = justCreated || tokenChanged || !upOrStarting;
|
|
97
|
+
if (deployed) {
|
|
98
|
+
await coolify.deploy(appId);
|
|
99
|
+
}
|
|
100
|
+
return { appId, deployed };
|
|
101
|
+
}
|
|
102
|
+
/** Whether the app's live `TUNNEL_TOKEN` is missing or differs from the token just fetched. */
|
|
103
|
+
async function tunnelTokenDiffers(coolify, appId, token) {
|
|
104
|
+
const live = (await coolify.listEnvs(appId)).find((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
|
|
105
|
+
return !live || live.value !== token;
|
|
106
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
2
|
import type { Plan, PlanStep, UnmanagedItem } from '../plan.js';
|
|
3
|
+
import { type AgoraHostReading } from './agora.js';
|
|
3
4
|
import type { LiveResource } from './live.js';
|
|
5
|
+
import { type OrgPlatformReadiness } from './platform-org.js';
|
|
4
6
|
/**
|
|
5
7
|
* What a {@link StepExecutor} (see `plan-apply.ts`) needs to actually make a
|
|
6
8
|
* step true, alongside the public {@link PlanStep} an operator reads.
|
|
@@ -60,12 +62,27 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
60
62
|
* by default unlike chat's own: {@link planCalls}, the org's own calls
|
|
61
63
|
* Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
|
|
62
64
|
* planned right after it and gated on the same flag: {@link planCallsService},
|
|
63
|
-
* the calls service itself (`wtfalch/calls`'s `packages/service`).
|
|
64
|
-
*
|
|
65
|
+
* the calls service itself (`wtfalch/calls`'s `packages/service`). #164 adds
|
|
66
|
+
* two more, unconditionally -- no `DeclaredOrg` field gates either, since
|
|
67
|
+
* every org's own Agora is always on: {@link planAgoraClient}, a fixed
|
|
68
|
+
* ZITADEL web client named `agora` (modeled on {@link planCmsClient}), and
|
|
69
|
+
* {@link planAgora}, the Agora itself as a Coolify app (modeled on
|
|
70
|
+
* {@link planSite}, minus the park branch -- foundry never deletes an
|
|
71
|
+
* Agora). #173 adds one more, gated on `org.hosting.enabled` like
|
|
72
|
+
* {@link planApp} itself: {@link planJobsRegistration}, registering the org's
|
|
73
|
+
* app with the platform's jobs scheduler, idempotent on the *recorded*
|
|
74
|
+
* resource id like {@link planOwner} rather than a live resource, since
|
|
75
|
+
* nothing in `live` can report a scheduler registration (no reader exists for
|
|
76
|
+
* it). Everything
|
|
77
|
+
* else in `live` that none of these eighteen singletons and however many
|
|
65
78
|
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
66
79
|
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
67
80
|
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
68
81
|
* Company Factory end to end", which wires real readers and executors
|
|
69
82
|
* against this seam.
|
|
70
83
|
*/
|
|
71
|
-
export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string
|
|
84
|
+
export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness): PlanDetail;
|
|
85
|
+
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
86
|
+
export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
|
|
87
|
+
/** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
|
|
88
|
+
export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registration_undelivered";
|