@wtfalch/foundry 0.59.0 → 0.61.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/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +5 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/org.d.ts +163 -22
- package/dist/org.js +419 -47
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +41 -1
- package/dist/plan/blue-green.js +80 -2
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/desktop-callback.js +12 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +373 -42
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +191 -1
- package/dist/plan/executor.js +1505 -90
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +2 -2
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.js +12 -1
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +7 -2
- package/dist/plan/post-logout-uris.js +7 -1
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +47 -40
- package/dist/plan/worker-app-database.js +150 -152
- package/dist/plan/worker-app.d.ts +6 -65
- package/dist/plan/worker-app.js +29 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +11 -0
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +10 -6
- package/dist/store/postgres.js +12 -2
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +4 -0
- package/dist/worker-app-config.d.ts +6 -10
- package/dist/worker-app-config.js +22 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +18 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { companyGithubFor } from '../org.js';
|
|
2
|
+
import { DeployKeysBlocked, deployKeysBlockedLine, sameSshKey, } from './github-deploy-keys.js';
|
|
3
|
+
import { appStepKey } from './platform-diff.js';
|
|
4
|
+
/**
|
|
5
|
+
* `uses.repo` (F12): the entry's app gets credentials for its own `entry.repo`. One `github:deploy-key` step per
|
|
6
|
+
* entry, keyed `<orgId>:<entry>`:
|
|
7
|
+
*
|
|
8
|
+
* 1. makes an ed25519 keypair and registers the public half on GitHub as a deploy key titled
|
|
9
|
+
* `foundry <org slug> <entry key>`, read-only unless `access` is `write`;
|
|
10
|
+
* 2. holds the private half at {@link repoKeyRef} and records the key's GitHub id (`repo_key.<entry>`);
|
|
11
|
+
* 3. writes `GIT_REPO_URL` (`git@github.com:<owner>/<name>.git`) and `GIT_SSH_KEY` to the entry's env, restarts
|
|
12
|
+
* the app, records `repo_key_applied.<entry>` last;
|
|
13
|
+
* 4. deletes every other key of that title (an older key after a change of access, or a key a failed run made).
|
|
14
|
+
*
|
|
15
|
+
* Halfway failures, and what the next apply does: a key made on GitHub whose private half was not held is found by
|
|
16
|
+
* its title and deleted, and another is made; a key held and on GitHub but not in the env is written; an env written
|
|
17
|
+
* and an old key not yet deleted is finished by step 4. No step leaves a key that nothing uses once an apply has
|
|
18
|
+
* run to its end. Removing the entry or `uses.repo` plans the same step in removal form: delete the recorded key and
|
|
19
|
+
* every key of the title, drop both env names, forget the held value, clear both markers.
|
|
20
|
+
*
|
|
21
|
+
* The private key reaches the held keys and the app's env and nothing else: not a plan, a log, a note, an error or a
|
|
22
|
+
* recorded id. A workspace copy (stage) is given none.
|
|
23
|
+
*
|
|
24
|
+
* Blocked, not failing (a plan line each): GitHub cannot be asked (no client wired), the credential is not held or
|
|
25
|
+
* lacks "Administration: Read and write" (the line names it and says it is William's step), no held key store that
|
|
26
|
+
* can put, the app is not recorded yet.
|
|
27
|
+
*/
|
|
28
|
+
export const GIT_REPO_URL_ENV = 'GIT_REPO_URL';
|
|
29
|
+
export const GIT_SSH_KEY_ENV = 'GIT_SSH_KEY';
|
|
30
|
+
/** What GitHub made for the entry: `{repo, id, access, pub}` as JSON. No secret. Cleared when the key is removed. */
|
|
31
|
+
export const repoKeyScopeKey = (entryKey) => `repo_key.${entryKey}`;
|
|
32
|
+
export const REPO_KEY_PREFIX = 'repo_key.';
|
|
33
|
+
/** What the env was last written with (repo, access, key id); a change replans the step. */
|
|
34
|
+
export const repoKeyAppliedScopeKey = (entryKey) => `repo_key_applied.${entryKey}`;
|
|
35
|
+
/** The held-key ref of an entry's private key. */
|
|
36
|
+
export const repoKeyRef = (orgId, entryKey) => `foundry:repo-key:${orgId}:${entryKey}`;
|
|
37
|
+
/** GitHub's title for the entry's key. */
|
|
38
|
+
export const repoKeyTitle = (org, entryKey) => `foundry ${org.slug} ${entryKey}`;
|
|
39
|
+
/** `git@github.com:<owner>/<name>.git`. */
|
|
40
|
+
export const repoGitUrl = (repo) => `git@github.com:${repo}.git`;
|
|
41
|
+
export function parseRecordedRepoKey(value) {
|
|
42
|
+
if (!value)
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
const v = JSON.parse(value);
|
|
46
|
+
if (typeof v.repo === 'string' &&
|
|
47
|
+
typeof v.id === 'number' &&
|
|
48
|
+
(v.access === 'read' || v.access === 'write') &&
|
|
49
|
+
typeof v.pub === 'string') {
|
|
50
|
+
return { repo: v.repo, id: v.id, access: v.access, pub: v.pub };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Not ours: treated as not recorded.
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
export const repoKeyAppliedMarker = (repo, access, id) => JSON.stringify([repo, access, id]);
|
|
59
|
+
/** The repo and, under the company's installation, its id. */
|
|
60
|
+
export function repoKeyTarget(org, repo) {
|
|
61
|
+
const github = companyGithubFor(org.hosting, repo);
|
|
62
|
+
return github ? { repo, installationId: github.installationId } : { repo };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The entries that declare `uses.repo` with a repo, in declared order. An entry that is not live (`live: false`)
|
|
66
|
+
* has no app, so it counts as none: a key it had is removed, and made again when it goes live.
|
|
67
|
+
*/
|
|
68
|
+
export const repoKeyEntries = (org) => org.hosting.enabled
|
|
69
|
+
? org.hosting.entries.filter((e) => e.uses?.repo && e.repo !== null && e.live !== false)
|
|
70
|
+
: [];
|
|
71
|
+
/** Entry keys that were given a key and no longer declare `uses.repo` (or are gone, or hosting is off). */
|
|
72
|
+
export function repoKeysToRemove(org, recorded) {
|
|
73
|
+
const wanted = new Set(repoKeyEntries(org).map((e) => e.key));
|
|
74
|
+
return Object.keys(recorded)
|
|
75
|
+
.filter((k) => k.startsWith(REPO_KEY_PREFIX) && recorded[k])
|
|
76
|
+
.map((k) => k.slice(REPO_KEY_PREFIX.length))
|
|
77
|
+
.filter((key) => !wanted.has(key));
|
|
78
|
+
}
|
|
79
|
+
/** Asks GitHub for the repo's deploy keys; a refusal or silence becomes a plain line. Never throws. */
|
|
80
|
+
export async function probeRepoKeys(port, target) {
|
|
81
|
+
try {
|
|
82
|
+
return { ok: true, keys: await port.list(target) };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const reason = error instanceof DeployKeysBlocked ? error.reason : 'unreachable';
|
|
86
|
+
return { ok: false, line: deployKeysBlockedLine(target, reason) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One step per `uses.repo` entry whose key is not made, held and written yet (or is gone from GitHub, or has the
|
|
91
|
+
* wrong access), and one removal step per recorded key whose entry no longer declares it. Every other path says why
|
|
92
|
+
* in `blocked` (reported in `unmanaged`).
|
|
93
|
+
*/
|
|
94
|
+
export function planEntryRepoKeys(org, recorded, plannedSteps, readiness) {
|
|
95
|
+
const steps = [];
|
|
96
|
+
const blocked = [];
|
|
97
|
+
const reads = readiness.repoKeys;
|
|
98
|
+
const appPlanned = (stepKey) => plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'app' && p.action.key === stepKey);
|
|
99
|
+
for (const entry of org.hosting.enabled ? org.hosting.entries : []) {
|
|
100
|
+
const use = entry.uses?.repo;
|
|
101
|
+
if (!use)
|
|
102
|
+
continue;
|
|
103
|
+
const stepKey = appStepKey(org, entry.key);
|
|
104
|
+
const key = `github:deploy-key:${stepKey}`;
|
|
105
|
+
const at = `${org.slug}'s ${entry.key} repo access`;
|
|
106
|
+
const note = (why) => blocked.push({ description: `${at} is not planned: ${why}`, key });
|
|
107
|
+
if (entry.repo === null) {
|
|
108
|
+
note('the entry has no repo');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (entry.live === false) {
|
|
112
|
+
note('the entry is not live, so it has no app to give a key to; it gets one once it is live');
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const rk = parseRecordedRepoKey(recorded[repoKeyScopeKey(entry.key)]);
|
|
116
|
+
const title = repoKeyTitle(org, entry.key);
|
|
117
|
+
const probe = reads?.probes[entry.key];
|
|
118
|
+
const inPlace = rk !== null &&
|
|
119
|
+
rk.repo === entry.repo &&
|
|
120
|
+
rk.access === use.access &&
|
|
121
|
+
recorded[repoKeyAppliedScopeKey(entry.key)] ===
|
|
122
|
+
repoKeyAppliedMarker(rk.repo, rk.access, rk.id);
|
|
123
|
+
const onGithub = inPlace &&
|
|
124
|
+
probe?.ok === true &&
|
|
125
|
+
probe.keys.some((k) => k.id === rk.id &&
|
|
126
|
+
k.title === title &&
|
|
127
|
+
k.readOnly === (use.access === 'read') &&
|
|
128
|
+
sameSshKey(k.key, rk.pub));
|
|
129
|
+
if (onGithub)
|
|
130
|
+
continue;
|
|
131
|
+
if (!reads?.store) {
|
|
132
|
+
note('the host gave no GitHub client for deploy keys');
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (probe && !probe.ok) {
|
|
136
|
+
if (inPlace) {
|
|
137
|
+
note(`its key is in place but GitHub could not be asked about it (${probe.line}); it is left as it is`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
note(`GitHub cannot make its deploy key yet: ${probe.line}`);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!probe) {
|
|
145
|
+
note('GitHub was not asked about its deploy keys');
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (readiness.canHoldSecrets !== true) {
|
|
149
|
+
note("the host's held keys cannot hold the key's private half");
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!recorded[`coolify:app:${stepKey}`] && !appPlanned(stepKey)) {
|
|
153
|
+
note("the entry's app is not recorded yet");
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
steps.push({
|
|
157
|
+
action: {
|
|
158
|
+
system: 'github',
|
|
159
|
+
kind: 'deploy-key',
|
|
160
|
+
key: stepKey,
|
|
161
|
+
desired: {
|
|
162
|
+
repo: entry.repo,
|
|
163
|
+
access: use.access,
|
|
164
|
+
...(companyGithubFor(org.hosting, entry.repo)
|
|
165
|
+
? { installationId: companyGithubFor(org.hosting, entry.repo)?.installationId }
|
|
166
|
+
: {}),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
step: {
|
|
170
|
+
description: `make a ${use.access === 'read' ? 'read-only' : 'read and write'} deploy key for ${entry.repo} as '${title}', hold its private half, write ${GIT_REPO_URL_ENV} and ${GIT_SSH_KEY_ENV} to ${org.slug}'s ${entry.key} env, restart the app`,
|
|
171
|
+
destroys: false,
|
|
172
|
+
migrates: false,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
for (const entryKey of repoKeysToRemove(org, recorded)) {
|
|
177
|
+
const rk = parseRecordedRepoKey(recorded[repoKeyScopeKey(entryKey)]);
|
|
178
|
+
const stepKey = appStepKey(org, entryKey);
|
|
179
|
+
const key = `github:deploy-key:${stepKey}`;
|
|
180
|
+
const at = `${org.slug}'s ${entryKey} repo access`;
|
|
181
|
+
if (!rk) {
|
|
182
|
+
blocked.push({
|
|
183
|
+
description: `${at} is not removed: the recorded key '${repoKeyScopeKey(entryKey)}' is not readable, so its GitHub key id is unknown; delete the deploy key titled '${repoKeyTitle(org, entryKey)}' by hand and clear the record`,
|
|
184
|
+
key,
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!reads?.store) {
|
|
189
|
+
blocked.push({
|
|
190
|
+
description: `${at} is not removed: the host gave no GitHub client for deploy keys, so key ${rk.id} on ${rk.repo} stays`,
|
|
191
|
+
key,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const probe = reads.probes[entryKey];
|
|
196
|
+
if (probe && !probe.ok) {
|
|
197
|
+
blocked.push({
|
|
198
|
+
description: `${at} is not removed: key ${rk.id} on ${rk.repo} stays because ${probe.line}`,
|
|
199
|
+
key,
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
steps.push({
|
|
204
|
+
action: {
|
|
205
|
+
system: 'github',
|
|
206
|
+
kind: 'deploy-key',
|
|
207
|
+
key: stepKey,
|
|
208
|
+
desired: { remove: true, repo: rk.repo },
|
|
209
|
+
},
|
|
210
|
+
step: {
|
|
211
|
+
description: `delete the deploy key ${rk.id} of ${rk.repo} made for ${org.slug}'s ${entryKey}, drop ${GIT_REPO_URL_ENV} and ${GIT_SSH_KEY_ENV}, forget the held private half`,
|
|
212
|
+
destroys: false,
|
|
213
|
+
migrates: false,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return { steps, blocked };
|
|
218
|
+
}
|
|
219
|
+
/** The entries whose repo GitHub should be asked about now: those that declare the flag and those with a key to remove. */
|
|
220
|
+
export function repoKeyProbeTargets(org, recorded) {
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const entry of repoKeyEntries(org)) {
|
|
223
|
+
if (!entry.repo)
|
|
224
|
+
continue;
|
|
225
|
+
out.push({ entryKey: entry.key, target: repoKeyTarget(org, entry.repo) });
|
|
226
|
+
}
|
|
227
|
+
for (const entryKey of repoKeysToRemove(org, recorded)) {
|
|
228
|
+
const rk = parseRecordedRepoKey(recorded[repoKeyScopeKey(entryKey)]);
|
|
229
|
+
if (rk)
|
|
230
|
+
out.push({ entryKey, target: repoKeyTarget(org, rk.repo) });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/** True when an entry of the org needs anything from GitHub: a key to make, check or remove. */
|
|
235
|
+
export const repoKeysInPlay = (org, recorded) => repoKeyProbeTargets(org, recorded).length > 0;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* `uses.auth.workspaces`: the entry's own customers get workspaces in the broker. Foundry makes one key per
|
|
4
|
+
* entry, holds it, and gives it to two places: the app's env (`WORKSPACES_API_KEY`) and, as a sha256, the
|
|
5
|
+
* broker entry (`workspacesKey`). The broker never sees the key and the plan never holds it.
|
|
6
|
+
*/
|
|
7
|
+
/** The marker that the entry's key was put in the held-key store: its ref, never the key. */
|
|
8
|
+
export declare const entryWorkspacesMintedScopeKey: (entryKey: string) => string;
|
|
9
|
+
/** The held-key ref of an entry's workspaces key. At most 123 characters: a UUID and an entry key of up to 63. */
|
|
10
|
+
export declare const workspacesKeyRef: (orgId: string, entryKey: string) => string;
|
|
11
|
+
/** The broker's env that must be set before an entry may declare workspaces. */
|
|
12
|
+
export declare const BROKER_WORKSPACES_DATABASE_ENV = "WORKSPACES_DATABASE_URL";
|
|
13
|
+
/** The held ref of the connection string for the database of the broker the org's entries are written to. The operator makes the database and holds it here. */
|
|
14
|
+
export declare const workspacesDatabaseRef: (org: Pick<DeclaredOrg, "slug" | "services">) => string;
|
|
15
|
+
/** The key as the broker holds it: sha256, lowercase hex. */
|
|
16
|
+
export declare const workspacesKeyHash: (key: string) => string;
|
|
17
|
+
/** The broker's public base URL as an app reaches it: the company's login host, where the broker answers under `/api`. */
|
|
18
|
+
export declare const workspacesUrl: (org: Pick<DeclaredOrg, "slug" | "domain" | "issuer">) => string;
|
|
19
|
+
/**
|
|
20
|
+
* What the broker entry says about the entry's workspaces, or `null` until the entry has an address. The invite
|
|
21
|
+
* link is on the entry's first public host: an entry that is not live has none (`entryHostnames`) and no
|
|
22
|
+
* broker entry either, so the link never points at a private workspace address.
|
|
23
|
+
*/
|
|
24
|
+
export declare function wantedWorkspaces(org: DeclaredOrg, entry: HostingEntry): {
|
|
25
|
+
roles: string[];
|
|
26
|
+
inviteUrl: string;
|
|
27
|
+
} | null;
|
|
28
|
+
interface HeldKeys {
|
|
29
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
30
|
+
put?(ref: string, value: string): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The entry's key: read from the held keys, else made and held first. The held ref is recorded and persisted
|
|
34
|
+
* before the key goes anywhere else, so a crash after the `put` hands the same key out on the next apply. A
|
|
35
|
+
* ref that is recorded but no longer held stops here and mints nothing: a second key would leave the broker
|
|
36
|
+
* and the app disagreeing. Errors name the ref, never the key. `secret` registers the key for redaction.
|
|
37
|
+
*/
|
|
38
|
+
export declare function ensureWorkspacesKey(input: {
|
|
39
|
+
orgId: string;
|
|
40
|
+
slug: string;
|
|
41
|
+
entryKey: string;
|
|
42
|
+
heldKeys: HeldKeys | undefined;
|
|
43
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
44
|
+
record: (key: string, value: string) => void;
|
|
45
|
+
persist: () => Promise<void>;
|
|
46
|
+
secret: (value: string) => void;
|
|
47
|
+
}): Promise<string>;
|
|
48
|
+
/** The key as already held, or an error that names the ref. The broker-entry step only reads; the app step made it. */
|
|
49
|
+
export declare function heldWorkspacesKey(heldKeys: Pick<HeldKeys, 'resolve'> | undefined, org: Pick<DeclaredOrg, 'id' | 'slug'>, entryKey: string): Promise<string>;
|
|
50
|
+
/**
|
|
51
|
+
* The connection string the operator holds at `ref` for the broker's workspaces database: `undefined` when
|
|
52
|
+
* nothing is held, an error naming the ref (never the value) when what is held is not a postgres URL.
|
|
53
|
+
*/
|
|
54
|
+
export declare function heldWorkspacesDatabase(heldKeys: Pick<HeldKeys, 'resolve'> | undefined, ref: string): Promise<string | undefined>;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { ownsServices } from '../org.js';
|
|
3
|
+
import { entryHostnames } from './hostnames.js';
|
|
4
|
+
import { loginHostnameFor } from './login-host.js';
|
|
5
|
+
import { orgTarget, platformTarget } from './own-services.js';
|
|
6
|
+
/**
|
|
7
|
+
* `uses.auth.workspaces`: the entry's own customers get workspaces in the broker. Foundry makes one key per
|
|
8
|
+
* entry, holds it, and gives it to two places: the app's env (`WORKSPACES_API_KEY`) and, as a sha256, the
|
|
9
|
+
* broker entry (`workspacesKey`). The broker never sees the key and the plan never holds it.
|
|
10
|
+
*/
|
|
11
|
+
/** The marker that the entry's key was put in the held-key store: its ref, never the key. */
|
|
12
|
+
export const entryWorkspacesMintedScopeKey = (entryKey) => `workspaces_key_minted.${entryKey}`;
|
|
13
|
+
/** The held-key ref of an entry's workspaces key. At most 123 characters: a UUID and an entry key of up to 63. */
|
|
14
|
+
export const workspacesKeyRef = (orgId, entryKey) => `foundry:workspaces-key:${orgId}:${entryKey}`;
|
|
15
|
+
/** The broker's env that must be set before an entry may declare workspaces. */
|
|
16
|
+
export const BROKER_WORKSPACES_DATABASE_ENV = 'WORKSPACES_DATABASE_URL';
|
|
17
|
+
/** The held ref of the connection string for the database of the broker the org's entries are written to. The operator makes the database and holds it here. */
|
|
18
|
+
export const workspacesDatabaseRef = (org) => (ownsServices(org) ? orgTarget(org) : platformTarget()).refs.brokerWorkspacesDatabase;
|
|
19
|
+
/** The key as the broker holds it: sha256, lowercase hex. */
|
|
20
|
+
export const workspacesKeyHash = (key) => createHash('sha256').update(key).digest('hex');
|
|
21
|
+
/** The broker's public base URL as an app reaches it: the company's login host, where the broker answers under `/api`. */
|
|
22
|
+
export const workspacesUrl = (org) => `https://${loginHostnameFor(org)}/api`;
|
|
23
|
+
/**
|
|
24
|
+
* What the broker entry says about the entry's workspaces, or `null` until the entry has an address. The invite
|
|
25
|
+
* link is on the entry's first public host: an entry that is not live has none (`entryHostnames`) and no
|
|
26
|
+
* broker entry either, so the link never points at a private workspace address.
|
|
27
|
+
*/
|
|
28
|
+
export function wantedWorkspaces(org, entry) {
|
|
29
|
+
const workspaces = entry.uses?.auth?.workspaces;
|
|
30
|
+
const host = entryHostnames(org, entry)[0];
|
|
31
|
+
if (!workspaces || !host)
|
|
32
|
+
return null;
|
|
33
|
+
return { roles: [...workspaces.roles], inviteUrl: `https://${host}${workspaces.invitePath}` };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The entry's key: read from the held keys, else made and held first. The held ref is recorded and persisted
|
|
37
|
+
* before the key goes anywhere else, so a crash after the `put` hands the same key out on the next apply. A
|
|
38
|
+
* ref that is recorded but no longer held stops here and mints nothing: a second key would leave the broker
|
|
39
|
+
* and the app disagreeing. Errors name the ref, never the key. `secret` registers the key for redaction.
|
|
40
|
+
*/
|
|
41
|
+
export async function ensureWorkspacesKey(input) {
|
|
42
|
+
const ref = workspacesKeyRef(input.orgId, input.entryKey);
|
|
43
|
+
const at = `coolify:app for ${input.slug}`;
|
|
44
|
+
const held = await input.heldKeys?.resolve(ref);
|
|
45
|
+
if (held) {
|
|
46
|
+
input.secret(held);
|
|
47
|
+
return held;
|
|
48
|
+
}
|
|
49
|
+
if (input.recorded()[entryWorkspacesMintedScopeKey(input.entryKey)]) {
|
|
50
|
+
throw new Error(`${at}: the workspaces key of entry '${input.entryKey}' was made but is no longer held (ref ${ref}); restore the held value`);
|
|
51
|
+
}
|
|
52
|
+
if (typeof input.heldKeys?.put !== 'function') {
|
|
53
|
+
throw new Error(`${at}: the held keys cannot put the workspaces key of entry '${input.entryKey}'`);
|
|
54
|
+
}
|
|
55
|
+
const key = randomBytes(32).toString('base64url');
|
|
56
|
+
input.secret(key);
|
|
57
|
+
await input.heldKeys.put(ref, key);
|
|
58
|
+
input.record(entryWorkspacesMintedScopeKey(input.entryKey), ref);
|
|
59
|
+
await input.persist();
|
|
60
|
+
return key;
|
|
61
|
+
}
|
|
62
|
+
/** The key as already held, or an error that names the ref. The broker-entry step only reads; the app step made it. */
|
|
63
|
+
export async function heldWorkspacesKey(heldKeys, org, entryKey) {
|
|
64
|
+
const ref = workspacesKeyRef(org.id, entryKey);
|
|
65
|
+
const key = await heldKeys?.resolve(ref);
|
|
66
|
+
if (!key) {
|
|
67
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: the workspaces key of entry '${entryKey}' is not held yet (ref ${ref}); the app step makes it`);
|
|
68
|
+
}
|
|
69
|
+
return key;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The connection string the operator holds at `ref` for the broker's workspaces database: `undefined` when
|
|
73
|
+
* nothing is held, an error naming the ref (never the value) when what is held is not a postgres URL.
|
|
74
|
+
*/
|
|
75
|
+
export async function heldWorkspacesDatabase(heldKeys, ref) {
|
|
76
|
+
const value = await heldKeys?.resolve(ref);
|
|
77
|
+
if (!value)
|
|
78
|
+
return undefined;
|
|
79
|
+
let protocol = '';
|
|
80
|
+
try {
|
|
81
|
+
protocol = new URL(value).protocol;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
protocol = '';
|
|
85
|
+
}
|
|
86
|
+
if (protocol !== 'postgres:' && protocol !== 'postgresql:') {
|
|
87
|
+
throw new Error(`the held key ${ref} is not a postgres:// connection string`);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|