@wtfalch/foundry 0.60.0 → 0.62.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 +14 -5
- package/dist/index.js +7 -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/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -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/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +184 -24
- package/dist/org.js +444 -49
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- 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 +60 -8
- package/dist/plan/blue-green.js +115 -10
- 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/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +15 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +552 -104
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-access.js +1 -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 +231 -19
- package/dist/plan/executor.js +1648 -129
- 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/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +7 -4
- 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.d.ts +3 -3
- package/dist/plan/org-names.js +15 -2
- package/dist/plan/org-postgres-migrate.js +1 -0
- 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-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -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 +13 -2
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +12 -3
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/providers-readers.js +20 -1
- 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 +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -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/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- 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 +13 -1
- 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 +20 -7
- package/dist/store/postgres.js +25 -2
- package/dist/store/schema.d.ts +73 -1
- package/dist/store/schema.js +10 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -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 +19 -2
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { WORKER_APP_ENV_NAME, WORKER_APP_KEYS_SECRET } from '../entry-rules.js';
|
|
3
|
+
import { APPS_DOMAIN } from '../org.js';
|
|
4
|
+
import { WORKER_APP_KEYS_URL_VAR, workerAppHasBuild } from '../worker-app-config.js';
|
|
5
|
+
import { WORKER_APP_DNS_RECORD, pendingWorkerAppMigrations, strayContainerImage, withCallTimeout, workerAppRegistryPrefix, workerAppShape, workerAppUpload, } from '../worker-app-upload.js';
|
|
6
|
+
import { fetchReleaseAsset, parseAppRepo, releaseAssetOf, } from './github.js';
|
|
7
|
+
/** The paths an MCP client reaches without signing in at Access: the MCP endpoint and the OAuth discovery documents. */
|
|
8
|
+
export function mcpMachinePaths(mcpPath) {
|
|
9
|
+
return [
|
|
10
|
+
...new Set([
|
|
11
|
+
mcpPath,
|
|
12
|
+
'/.well-known/oauth-protected-resource',
|
|
13
|
+
'/.well-known/oauth-authorization-server',
|
|
14
|
+
]),
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
const WORKSPACE_SUFFIX = '-work';
|
|
18
|
+
export const appScriptName = (key, copy) => copy === 'live' ? key : `${key}${WORKSPACE_SUFFIX}`;
|
|
19
|
+
export const appHyperdriveName = (script) => `${script}-db`;
|
|
20
|
+
const APP_KEY = /^[a-z](?:[a-z0-9-]{0,52}[a-z0-9])?$/;
|
|
21
|
+
const HOST = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
22
|
+
const EMAIL = /^[^\s@<>"]{1,64}@[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
|
23
|
+
const MACHINE_PATH = /^(\/[A-Za-z0-9._~-]+)+$/;
|
|
24
|
+
const SIGN_IN_NAME = /^(AUTH_[A-Z0-9_]+|MCP_[A-Z0-9_]+|KEYS_URL)$/;
|
|
25
|
+
const GATE_EMAILS_MAX = 50;
|
|
26
|
+
const MACHINE_PATHS_MAX = 20;
|
|
27
|
+
const ARTIFACT_MAX_BYTES = 10 * 1024 * 1024;
|
|
28
|
+
const CALL_TIMEOUT_MS = 120_000;
|
|
29
|
+
const UPLOAD_TIMEOUT_MS = 300_000;
|
|
30
|
+
const ROLLOUT_TIMEOUT_MS = 600_000;
|
|
31
|
+
const ROLLOUT_POLL_MS = 5_000;
|
|
32
|
+
const SIGN_IN_VARS = ['AUTH_ORGANIZATION_ID', 'AUTH_CLIENT_ID', 'AUTH_ISSUER'];
|
|
33
|
+
const MCP_VARS = ['MCP_AUDIENCE', 'MCP_RESOURCE', 'MCP_ISSUER'];
|
|
34
|
+
const AUTH_APP_KEY = 'AUTH_APP_KEY';
|
|
35
|
+
const PEOPLE_POLICY = 'people';
|
|
36
|
+
const SELF_HOSTED = 'self_hosted';
|
|
37
|
+
const MACHINES_POLICY = 'machines';
|
|
38
|
+
/** A refusal or a failed call, already in plain words. */
|
|
39
|
+
class Stop extends Error {
|
|
40
|
+
step;
|
|
41
|
+
reachableWithoutGate;
|
|
42
|
+
constructor(step, message, reachableWithoutGate = false) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.step = step;
|
|
45
|
+
this.reachableWithoutGate = reachableWithoutGate;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** What each step asks of the token, named as Cloudflare's token page does. */
|
|
49
|
+
const PERMISSION = {
|
|
50
|
+
address: 'Zone: Read, DNS: Edit and Workers Routes: Edit',
|
|
51
|
+
build: 'Workers Scripts: Edit',
|
|
52
|
+
upload: 'Workers Scripts: Edit',
|
|
53
|
+
secrets: 'Workers Scripts: Edit',
|
|
54
|
+
database: 'Hyperdrive: Edit',
|
|
55
|
+
containers: 'Containers: Edit',
|
|
56
|
+
gate: 'Access: Apps and Policies: Edit',
|
|
57
|
+
remove: 'Workers Scripts: Edit',
|
|
58
|
+
};
|
|
59
|
+
function callFailure(step, what, error) {
|
|
60
|
+
const status = error?.status;
|
|
61
|
+
const timedOut = error instanceof Error && /no answer within/.test(error.message);
|
|
62
|
+
if (timedOut)
|
|
63
|
+
return new Stop(step, `Cloudflare did not answer in time while we tried to ${what}. Try again.`);
|
|
64
|
+
if (status === 401) {
|
|
65
|
+
return new Stop(step, 'Cloudflare did not accept the API token. Make a new token and save it under Settings › Connections.');
|
|
66
|
+
}
|
|
67
|
+
if (status === 403) {
|
|
68
|
+
return new Stop(step, `Cloudflare refused to let us ${what}: the API token needs ${PERMISSION[step] ?? 'more permissions'}. Add it to the token in Cloudflare and try again.`);
|
|
69
|
+
}
|
|
70
|
+
return new Stop(step, `Cloudflare could not ${what}${typeof status === 'number' ? ` (it answered ${status})` : ''}. Try again; if it keeps failing, look at the app in your Cloudflare account.`);
|
|
71
|
+
}
|
|
72
|
+
const sorted = (list) => [...list].sort();
|
|
73
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
74
|
+
/** The domain a person would add to Cloudflare for `host`: its last two labels. */
|
|
75
|
+
const domainOf = (host) => host.split('.').slice(-2).join('.');
|
|
76
|
+
const onPlatformZone = (host) => host === APPS_DOMAIN || host.endsWith(`.${APPS_DOMAIN}`);
|
|
77
|
+
function parsePostgresUrl(url) {
|
|
78
|
+
const refuse = () => new Stop('database', 'The database address is not a Postgres address. It looks like postgres://user:password@host:5432/name.');
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = new URL(url);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw refuse();
|
|
85
|
+
}
|
|
86
|
+
if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:')
|
|
87
|
+
throw refuse();
|
|
88
|
+
let database;
|
|
89
|
+
let user;
|
|
90
|
+
let password;
|
|
91
|
+
try {
|
|
92
|
+
database = decodeURIComponent(parsed.pathname.replace(/^\//, ''));
|
|
93
|
+
user = decodeURIComponent(parsed.username);
|
|
94
|
+
password = decodeURIComponent(parsed.password);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw refuse();
|
|
98
|
+
}
|
|
99
|
+
const port = parsed.port ? Number(parsed.port) : 5432;
|
|
100
|
+
if (!parsed.hostname || !database || !user || !password || !Number.isInteger(port))
|
|
101
|
+
throw refuse();
|
|
102
|
+
return {
|
|
103
|
+
scheme: parsed.protocol === 'postgresql:' ? 'postgresql' : 'postgres',
|
|
104
|
+
host: parsed.hostname,
|
|
105
|
+
port,
|
|
106
|
+
database,
|
|
107
|
+
user,
|
|
108
|
+
password,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Cloudflare's upload metadata for `upload`. Secrets are kept across uploads. */
|
|
112
|
+
function uploadMetadata(upload) {
|
|
113
|
+
const migrations = upload.migrations;
|
|
114
|
+
return {
|
|
115
|
+
compatibility_date: upload.compatibilityDate,
|
|
116
|
+
...(upload.compatibilityFlags.length ? { compatibility_flags: upload.compatibilityFlags } : {}),
|
|
117
|
+
bindings: upload.bindings,
|
|
118
|
+
...(migrations
|
|
119
|
+
? {
|
|
120
|
+
migrations: {
|
|
121
|
+
...(migrations.oldTag === undefined ? {} : { old_tag: migrations.oldTag }),
|
|
122
|
+
new_tag: migrations.newTag,
|
|
123
|
+
steps: migrations.steps.map((step) => ({
|
|
124
|
+
...(step.newSqliteClasses?.length
|
|
125
|
+
? { new_sqlite_classes: step.newSqliteClasses }
|
|
126
|
+
: {}),
|
|
127
|
+
...(step.deletedClasses?.length ? { deleted_classes: step.deletedClasses } : {}),
|
|
128
|
+
...(step.renamedClasses?.length ? { renamed_classes: step.renamedClasses } : {}),
|
|
129
|
+
})),
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
...(upload.containers ? { containers: upload.containers.map((c) => ({ class_name: c })) } : {}),
|
|
134
|
+
observability: upload.observability,
|
|
135
|
+
keep_bindings: ['secret_text'],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function makeContext(input, deps) {
|
|
139
|
+
const cf = deps.cloudflare(input.account);
|
|
140
|
+
const ms = deps.timeoutMs ?? CALL_TIMEOUT_MS;
|
|
141
|
+
return {
|
|
142
|
+
cf,
|
|
143
|
+
steps: [],
|
|
144
|
+
deps,
|
|
145
|
+
async call(step, what, task, over) {
|
|
146
|
+
try {
|
|
147
|
+
return await withCallTimeout(over ?? ms, what, task);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw callFailure(step, what, error);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Never let a value we were handed reach a message, whatever path built it. */
|
|
156
|
+
function scrub(text, hidden) {
|
|
157
|
+
let out = text;
|
|
158
|
+
for (const value of hidden) {
|
|
159
|
+
if (value.length >= 4)
|
|
160
|
+
out = out.split(value).join('[hidden]');
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
async function settle(run, ctx, hidden) {
|
|
165
|
+
const steps = ctx?.steps ?? [];
|
|
166
|
+
try {
|
|
167
|
+
if (!ctx)
|
|
168
|
+
throw new Stop('check', 'Could not reach your Cloudflare account. Check the connection under Settings › Connections.');
|
|
169
|
+
const url = await run(ctx);
|
|
170
|
+
return { ok: true, steps: steps.map((s) => scrub(s, hidden)), url: scrub(url, hidden) };
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const stop = error instanceof Stop
|
|
174
|
+
? error
|
|
175
|
+
: new Stop('unexpected', 'Something went wrong while setting up the app. Try again.');
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
step: stop.step,
|
|
179
|
+
message: scrub(stop.message, hidden),
|
|
180
|
+
steps: steps.map((s) => scrub(s, hidden)),
|
|
181
|
+
...(stop.reachableWithoutGate ? { reachableWithoutGate: true } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** `removing`: a copy under a legacy `-work` key may still be taken down; only a deploy refuses it. */
|
|
186
|
+
function checkNames(app, removing = false) {
|
|
187
|
+
if (!APP_KEY.test(app.key)) {
|
|
188
|
+
throw new Stop('check', 'The app’s key must be lower-case letters, digits and dashes, start with a letter and be at most 54 characters.');
|
|
189
|
+
}
|
|
190
|
+
// `<key>-work` is the workspace copy of `<key>`: an app keyed `foo-work` would be foo's workspace.
|
|
191
|
+
if (!removing && app.key.endsWith(WORKSPACE_SUFFIX)) {
|
|
192
|
+
throw new Stop('check', 'The app’s key may not end in -work: that name is kept for the workspace of another app. Pick another key.');
|
|
193
|
+
}
|
|
194
|
+
if (!HOST.test(app.host)) {
|
|
195
|
+
throw new Stop('check', `${app.host.slice(0, 80)} is not a web address we can use.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function findAddress(ctx, app, script) {
|
|
199
|
+
if (onPlatformZone(app.host)) {
|
|
200
|
+
if (app.copy === 'live') {
|
|
201
|
+
throw new Stop('address', `${app.host} is on companyincluded.app, which your own Cloudflare account cannot serve yet. Use a domain that is in your Cloudflare account for the live app.`);
|
|
202
|
+
}
|
|
203
|
+
const subdomain = await ctx.call('address', 'read your workers.dev name', () => ctx.cf.getWorkersSubdomain());
|
|
204
|
+
if (!subdomain) {
|
|
205
|
+
throw new Stop('address', 'Your Cloudflare account has no workers.dev name yet. Open Workers & Pages in Cloudflare once to choose it, then try again.');
|
|
206
|
+
}
|
|
207
|
+
const host = `${script}.${subdomain}.workers.dev`;
|
|
208
|
+
ctx.steps.push(`The workspace is served at ${host}`);
|
|
209
|
+
return { kind: 'workers.dev', host };
|
|
210
|
+
}
|
|
211
|
+
const zone = await ctx.call('address', `find ${domainOf(app.host)} in your account`, () => ctx.cf.findZoneByApex(app.host));
|
|
212
|
+
if (!zone) {
|
|
213
|
+
throw new Stop('address', `${domainOf(app.host)} is not in your Cloudflare account. Add it there first, or pick a name on companyincluded.app.`);
|
|
214
|
+
}
|
|
215
|
+
if (zone.status !== 'active') {
|
|
216
|
+
throw new Stop('address', `${zone.name} is in your Cloudflare account but is not active yet. Finish adding it in Cloudflare (its name servers), then try again.`);
|
|
217
|
+
}
|
|
218
|
+
ctx.steps.push(`Found ${zone.name} in your Cloudflare account`);
|
|
219
|
+
return { kind: 'zone', zone: { id: zone.id, name: zone.name }, host: app.host };
|
|
220
|
+
}
|
|
221
|
+
/** Refuses a route or record on the host that something else made, before anything is written. */
|
|
222
|
+
async function checkAddressFree(ctx, address, script) {
|
|
223
|
+
if (address.kind !== 'zone')
|
|
224
|
+
return;
|
|
225
|
+
const pattern = `${address.host}/*`;
|
|
226
|
+
const routes = await ctx.call('address', `read the routes of ${address.zone.name}`, () => ctx.cf.listWorkerRoutes(address.zone.id));
|
|
227
|
+
const taken = routes.find((r) => r.pattern === pattern && r.script !== script);
|
|
228
|
+
if (taken) {
|
|
229
|
+
throw new Stop('address', `${address.host} already sends its visitors to ${taken.script ? `another Worker (${taken.script})` : 'nothing'} in your Cloudflare account. Remove that route in Cloudflare, or pick another name.`);
|
|
230
|
+
}
|
|
231
|
+
const records = await ctx.call('address', `read the DNS records of ${address.host}`, () => ctx.cf.listDnsRecords(address.zone.id, { name: address.host }));
|
|
232
|
+
const foreign = records.find((r) => !(r.type === WORKER_APP_DNS_RECORD.type && r.content === WORKER_APP_DNS_RECORD.content));
|
|
233
|
+
if (foreign) {
|
|
234
|
+
throw new Stop('address', `${address.host} already has a DNS record (${foreign.type}) in your Cloudflare account. Remove it there, or pick another name.`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function publishAddress(ctx, address, script) {
|
|
238
|
+
if (address.kind === 'workers.dev') {
|
|
239
|
+
await ctx.call('address', 'turn on the workers.dev address', () => ctx.cf.setWorkerScriptSubdomain(script, true));
|
|
240
|
+
ctx.steps.push(`Turned on ${address.host}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// The route is the address; workers.dev was turned off right after the upload.
|
|
244
|
+
const { zone, host } = address;
|
|
245
|
+
const records = await ctx.call('address', `read the DNS records of ${host}`, () => ctx.cf.listDnsRecords(zone.id, { name: host }));
|
|
246
|
+
if (records.some((r) => r.type === WORKER_APP_DNS_RECORD.type && r.content === WORKER_APP_DNS_RECORD.content)) {
|
|
247
|
+
ctx.steps.push(`The DNS record of ${host} is in place`);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
await ctx.call('address', `make the DNS record of ${host}`, () => ctx.cf.createDnsRecord(zone.id, { ...WORKER_APP_DNS_RECORD, name: host, proxied: true }));
|
|
251
|
+
ctx.steps.push(`Made the DNS record of ${host}`);
|
|
252
|
+
}
|
|
253
|
+
const pattern = `${host}/*`;
|
|
254
|
+
const routes = await ctx.call('address', `read the routes of ${zone.name}`, () => ctx.cf.listWorkerRoutes(zone.id));
|
|
255
|
+
if (routes.some((r) => r.pattern === pattern && r.script === script)) {
|
|
256
|
+
ctx.steps.push(`The route ${pattern} is in place`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
await ctx.call('address', `make the route ${pattern}`, () => ctx.cf.createWorkerRoute(zone.id, { pattern, script }));
|
|
260
|
+
ctx.steps.push(`Made the route ${pattern}`);
|
|
261
|
+
}
|
|
262
|
+
// A route of this app on another name of the same zone is from an earlier address.
|
|
263
|
+
for (const old of routes.filter((r) => r.script === script && r.pattern !== pattern)) {
|
|
264
|
+
await ctx.call('address', `remove the old route ${old.pattern}`, () => ctx.cf.deleteWorkerRoute(zone.id, old.id));
|
|
265
|
+
ctx.steps.push(`Removed the old route ${old.pattern}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// ---- the gate ---------------------------------------------------------------
|
|
269
|
+
const isOurAccessApp = (a, script, host) => (a.domain === host && a.name === script) ||
|
|
270
|
+
(a.domain.startsWith(`${host}/`) && a.name === `${script} ${a.domain.slice(host.length)}`);
|
|
271
|
+
/**
|
|
272
|
+
* Our gate app holds our one rule and nothing else: any other rule on it (an `everyone` allow
|
|
273
|
+
* added in Cloudflare, a second copy of ours) would open it, so it is removed, first.
|
|
274
|
+
*/
|
|
275
|
+
async function ensurePolicy(ctx, appId, policy, label) {
|
|
276
|
+
const live = await ctx.call('gate', `read the Access rules of ${label}`, () => ctx.cf.listAccessPolicies(appId));
|
|
277
|
+
const found = live.find((p) => p.name === policy.name);
|
|
278
|
+
const others = live.filter((p) => p !== found);
|
|
279
|
+
for (const other of others) {
|
|
280
|
+
await ctx.call('gate', `remove an Access rule of ${label}`, () => ctx.cf.deleteAccessPolicy(appId, other.id));
|
|
281
|
+
}
|
|
282
|
+
if (others.length > 0) {
|
|
283
|
+
ctx.steps.push(`Removed ${others.length} Access ${others.length === 1 ? 'rule' : 'rules'} on ${label} that Boule did not make`);
|
|
284
|
+
}
|
|
285
|
+
if (found && found.decision === policy.decision && sameJson(found.include, policy.include)) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
if (found) {
|
|
289
|
+
await ctx.call('gate', `update the Access rule of ${label}`, () => ctx.cf.updateAccessPolicy(appId, found.id, { ...policy, precedence: 1 }));
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
await ctx.call('gate', `make the Access rule of ${label}`, () => ctx.cf.createAccessPolicy(appId, { ...policy, precedence: 1 }));
|
|
293
|
+
}
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Our Access app on `domain`, self-hosted. One of another type (which gates nothing) or a second
|
|
298
|
+
* copy is removed after the right one exists, so the domain is never left without a gate.
|
|
299
|
+
*/
|
|
300
|
+
async function ensureAccessApp(ctx, ours, domain, name, what, made) {
|
|
301
|
+
const same = ours.filter((a) => a.domain === domain);
|
|
302
|
+
let id = same.find((a) => a.type === SELF_HOSTED)?.id;
|
|
303
|
+
if (!id) {
|
|
304
|
+
const created = await ctx.call('gate', `make the Access app for ${what}`, () => ctx.cf.createAccessApp({ name, domain, type: SELF_HOSTED, session_duration: '24h' }));
|
|
305
|
+
id = created.id;
|
|
306
|
+
ctx.steps.push(made);
|
|
307
|
+
}
|
|
308
|
+
for (const extra of same.filter((a) => a.id !== id)) {
|
|
309
|
+
await ctx.call('gate', `remove the Access app ${domain}`, () => ctx.cf.deleteAccessApp(extra.id));
|
|
310
|
+
ctx.steps.push(`Removed an Access app on ${domain} that was ${extra.type === SELF_HOSTED ? 'a second copy' : 'not self-hosted'}`);
|
|
311
|
+
}
|
|
312
|
+
return id;
|
|
313
|
+
}
|
|
314
|
+
async function ensureGate(ctx, host, script, gate) {
|
|
315
|
+
const apps = await ctx.call('gate', 'read your Access apps', () => ctx.cf.listAccessApps());
|
|
316
|
+
const ours = apps.filter((a) => isOurAccessApp(a, script, host));
|
|
317
|
+
if (!gate) {
|
|
318
|
+
for (const app of ours) {
|
|
319
|
+
await ctx.call('gate', `remove the Access app ${app.domain}`, () => ctx.cf.deleteAccessApp(app.id));
|
|
320
|
+
ctx.steps.push(`Removed the gate on ${app.domain}`);
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Anything on the host or a path of it that is not ours: we cannot tell what it lets through.
|
|
325
|
+
const foreign = apps.find((a) => (a.domain === host || a.domain.startsWith(`${host}/`)) && !isOurAccessApp(a, script, host));
|
|
326
|
+
if (foreign) {
|
|
327
|
+
throw new Stop('gate', `${host} already has a Cloudflare Access app (${foreign.name}) that Boule did not make. Remove it in Cloudflare, then try again.`);
|
|
328
|
+
}
|
|
329
|
+
const emails = sorted(gate.emails.map((e) => e.trim().toLowerCase()));
|
|
330
|
+
// The machine paths first: they are what the gate must let through once it exists.
|
|
331
|
+
const wanted = new Set(gate.machinePaths);
|
|
332
|
+
for (const path of sorted([...wanted])) {
|
|
333
|
+
const appId = await ensureAccessApp(ctx, ours, `${host}${path}`, `${script} ${path}`, path, `Let machines reach ${path} without signing in`);
|
|
334
|
+
if (await ensurePolicy(ctx, appId, { name: MACHINES_POLICY, decision: 'bypass', include: [{ everyone: {} }] }, path)) {
|
|
335
|
+
ctx.steps.push(`Set the rule that lets machines reach ${path}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for (const app of ours) {
|
|
339
|
+
if (app.domain === host || wanted.has(app.domain.slice(host.length)))
|
|
340
|
+
continue;
|
|
341
|
+
await ctx.call('gate', `remove the Access app ${app.domain}`, () => ctx.cf.deleteAccessApp(app.id));
|
|
342
|
+
ctx.steps.push(`Removed the open path ${app.domain.slice(host.length)}`);
|
|
343
|
+
}
|
|
344
|
+
const wholeId = await ensureAccessApp(ctx, ours, host, script, host, `Made the gate on ${host}`);
|
|
345
|
+
const changed = await ensurePolicy(ctx, wholeId, {
|
|
346
|
+
name: PEOPLE_POLICY,
|
|
347
|
+
decision: 'allow',
|
|
348
|
+
include: emails.map((email) => ({ email: { email } })),
|
|
349
|
+
}, host);
|
|
350
|
+
ctx.steps.push(changed
|
|
351
|
+
? `Let ${emails.length} ${emails.length === 1 ? 'person' : 'people'} through the gate`
|
|
352
|
+
: `The gate lets the same ${emails.length} ${emails.length === 1 ? 'person' : 'people'} through`);
|
|
353
|
+
}
|
|
354
|
+
function checkGate(gate, copy) {
|
|
355
|
+
if (!gate) {
|
|
356
|
+
if (copy === 'workspace') {
|
|
357
|
+
throw new Stop('check', 'A workspace needs a list of the people who may open it.');
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const emails = gate.emails.map((e) => e.trim().toLowerCase());
|
|
362
|
+
if (emails.length === 0) {
|
|
363
|
+
throw new Stop('check', 'Add at least one person who may open the workspace.');
|
|
364
|
+
}
|
|
365
|
+
if (emails.length > GATE_EMAILS_MAX) {
|
|
366
|
+
throw new Stop('check', `At most ${GATE_EMAILS_MAX} people may be let through the gate.`);
|
|
367
|
+
}
|
|
368
|
+
const bad = emails.find((e) => !EMAIL.test(e));
|
|
369
|
+
if (bad !== undefined)
|
|
370
|
+
throw new Stop('check', `${bad.slice(0, 80)} is not an email address.`);
|
|
371
|
+
if (new Set(emails).size !== emails.length) {
|
|
372
|
+
throw new Stop('check', 'The same email address is in the list twice.');
|
|
373
|
+
}
|
|
374
|
+
if (gate.machinePaths.length > MACHINE_PATHS_MAX) {
|
|
375
|
+
throw new Stop('check', `At most ${MACHINE_PATHS_MAX} paths may be open to machines.`);
|
|
376
|
+
}
|
|
377
|
+
for (const path of gate.machinePaths) {
|
|
378
|
+
if (!MACHINE_PATH.test(path) ||
|
|
379
|
+
path.split('/').some((s) => s === '.' || s === '..') ||
|
|
380
|
+
path.length > 200) {
|
|
381
|
+
throw new Stop('check', `${path.slice(0, 80)} is not a path we can open to machines. A path starts with / and holds letters, digits and . _ ~ -.`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ---- secrets, containers, database --------------------------------------------
|
|
386
|
+
async function putSecrets(ctx, script, secrets) {
|
|
387
|
+
// Cloudflare never shows a value, so each one is written again; a write of the same value changes nothing.
|
|
388
|
+
for (const [name, value] of Object.entries(secrets).sort(([a], [b]) => a.localeCompare(b))) {
|
|
389
|
+
await ctx.call('secrets', `set the secret ${name}`, () => ctx.cf.putWorkerSecret(script, name, value));
|
|
390
|
+
}
|
|
391
|
+
const count = Object.keys(secrets).length;
|
|
392
|
+
ctx.steps.push(`Set ${count} ${count === 1 ? 'secret' : 'secrets'}`);
|
|
393
|
+
}
|
|
394
|
+
/** After the upload: a secret the build no longer names goes once the new code runs. */
|
|
395
|
+
async function pruneSecrets(ctx, script, secrets) {
|
|
396
|
+
const live = await ctx.call('secrets', 'read the app’s secret names', () => ctx.cf.listWorkerSecrets(script));
|
|
397
|
+
for (const { name } of live) {
|
|
398
|
+
if (name in secrets)
|
|
399
|
+
continue;
|
|
400
|
+
await ctx.call('secrets', `remove the old secret ${name}`, () => ctx.cf.deleteWorkerSecret(script, name));
|
|
401
|
+
ctx.steps.push(`Removed the old secret ${name}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const containerAppName = (script, className) => `${script}-${className}`.toLowerCase();
|
|
405
|
+
async function waitForRollout(ctx, appId, rolloutId, className) {
|
|
406
|
+
const deadline = Date.now() + (ctx.deps.rolloutTimeoutMs ?? ROLLOUT_TIMEOUT_MS);
|
|
407
|
+
const poll = ctx.deps.rolloutPollMs ?? ROLLOUT_POLL_MS;
|
|
408
|
+
for (;;) {
|
|
409
|
+
const { status } = await ctx.call('containers', `read the ${className} rollout`, () => ctx.cf.getContainerRollout(appId, rolloutId));
|
|
410
|
+
if (status === 'completed')
|
|
411
|
+
return;
|
|
412
|
+
if (status === 'reverted' || status === 'replaced') {
|
|
413
|
+
throw new Stop('containers', `The new ${className} container image did not start (Cloudflare ${status} it). The app still runs the old one. Look at the build, then try again.`);
|
|
414
|
+
}
|
|
415
|
+
if (Date.now() >= deadline) {
|
|
416
|
+
throw new Stop('containers', `The new ${className} container image is still rolling out. Try again in a few minutes.`);
|
|
417
|
+
}
|
|
418
|
+
await new Promise((resolve) => setTimeout(resolve, poll));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function syncContainers(ctx, script, containers) {
|
|
422
|
+
const [namespaces, apps] = await Promise.all([
|
|
423
|
+
ctx.call('containers', 'read the app’s Durable Objects', () => ctx.cf.listDurableObjectNamespaces()),
|
|
424
|
+
ctx.call('containers', 'read your container applications', () => ctx.cf.listContainerApplications()),
|
|
425
|
+
]);
|
|
426
|
+
const ofScript = namespaces.filter((ns) => ns.script === script);
|
|
427
|
+
const used = new Set();
|
|
428
|
+
for (const container of containers) {
|
|
429
|
+
const namespace = ofScript.find((ns) => ns.className === container.className);
|
|
430
|
+
if (!namespace) {
|
|
431
|
+
throw new Stop('containers', `The build has no Durable Object ${container.className} for its container. Check the build’s build.json.`);
|
|
432
|
+
}
|
|
433
|
+
const name = containerAppName(script, container.className);
|
|
434
|
+
const live = apps.find((a) => a.namespaceId === namespace.id);
|
|
435
|
+
if (!live) {
|
|
436
|
+
const { id } = await ctx.call('containers', `make the ${container.className} container`, () => ctx.cf.createContainerApplication({
|
|
437
|
+
name,
|
|
438
|
+
image: container.image,
|
|
439
|
+
instanceType: container.instanceType,
|
|
440
|
+
maxInstances: container.maxInstances,
|
|
441
|
+
namespaceId: namespace.id,
|
|
442
|
+
}));
|
|
443
|
+
used.add(id);
|
|
444
|
+
ctx.steps.push(`Made the ${container.className} container`);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
used.add(live.id);
|
|
448
|
+
let changed = false;
|
|
449
|
+
if (live.maxInstances !== container.maxInstances) {
|
|
450
|
+
await ctx.call('containers', `set how many ${container.className} containers may run`, () => ctx.cf.updateContainerApplication(live.id, { maxInstances: container.maxInstances }));
|
|
451
|
+
changed = true;
|
|
452
|
+
}
|
|
453
|
+
if (live.image !== container.image || live.instanceType !== container.instanceType) {
|
|
454
|
+
const rollout = await ctx.call('containers', `roll out the new ${container.className} image`, () => ctx.cf.rolloutContainerApplication(live.id, {
|
|
455
|
+
image: container.image,
|
|
456
|
+
instanceType: container.instanceType,
|
|
457
|
+
description: 'Boule: new build',
|
|
458
|
+
}));
|
|
459
|
+
await waitForRollout(ctx, live.id, rollout.id, container.className);
|
|
460
|
+
changed = true;
|
|
461
|
+
}
|
|
462
|
+
ctx.steps.push(changed
|
|
463
|
+
? `Brought the ${container.className} container to the new build`
|
|
464
|
+
: `The ${container.className} container is current`);
|
|
465
|
+
}
|
|
466
|
+
// A container of this script whose class the build no longer runs in a container.
|
|
467
|
+
const ours = new Set(ofScript.map((ns) => ns.id));
|
|
468
|
+
for (const app of apps) {
|
|
469
|
+
if (used.has(app.id) || !ours.has(app.namespaceId))
|
|
470
|
+
continue;
|
|
471
|
+
await ctx.call('containers', `remove the container ${app.name}`, () => ctx.cf.deleteContainerApplication(app.id));
|
|
472
|
+
ctx.steps.push(`Removed the container ${app.name}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async function ensureHyperdrive(ctx, script, origin) {
|
|
476
|
+
const name = appHyperdriveName(script);
|
|
477
|
+
const configs = await ctx.call('database', 'read your Hyperdrive configs', () => ctx.cf.listHyperdriveConfigs());
|
|
478
|
+
const found = configs.find((c) => c.name === name);
|
|
479
|
+
// Always written: Cloudflare never shows the password, so a changed one could not be seen.
|
|
480
|
+
if (found) {
|
|
481
|
+
await ctx.call('database', 'update the database link', () => ctx.cf.updateHyperdriveConfig(found.id, name, origin));
|
|
482
|
+
ctx.steps.push(`Updated the database link ${name}`);
|
|
483
|
+
return found.id;
|
|
484
|
+
}
|
|
485
|
+
const { id } = await ctx.call('database', 'make the database link', () => ctx.cf.createHyperdriveConfig(name, origin));
|
|
486
|
+
ctx.steps.push(`Made the database link ${name}`);
|
|
487
|
+
return id;
|
|
488
|
+
}
|
|
489
|
+
async function deleteHyperdrive(ctx, step, script) {
|
|
490
|
+
const name = appHyperdriveName(script);
|
|
491
|
+
const configs = await ctx.call(step, 'read your Hyperdrive configs', () => ctx.cf.listHyperdriveConfigs());
|
|
492
|
+
for (const config of configs.filter((c) => c.name === name)) {
|
|
493
|
+
await ctx.call(step, 'remove the database link', () => ctx.cf.deleteHyperdriveConfig(config.id));
|
|
494
|
+
ctx.steps.push(`Removed the database link ${name}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/** Everything that needs no call: names, env, secrets, images, the database address. */
|
|
498
|
+
function prepare(input) {
|
|
499
|
+
checkNames(input.app);
|
|
500
|
+
const app = input.entry.workerApp;
|
|
501
|
+
if (!app)
|
|
502
|
+
throw new Stop('check', 'This app is not a Worker app.');
|
|
503
|
+
if (!workerAppHasBuild(app)) {
|
|
504
|
+
throw new Stop('check', 'The app has no build yet. Publish a release with its build.json first.');
|
|
505
|
+
}
|
|
506
|
+
checkGate(input.gate ?? null, input.app.copy);
|
|
507
|
+
const repo = input.app.repo;
|
|
508
|
+
if (!parseAppRepo(repo)) {
|
|
509
|
+
throw new Stop('check', 'The app needs its GitHub repository, written owner/name (for example nordvik/terminal).');
|
|
510
|
+
}
|
|
511
|
+
const asset = releaseAssetOf(app.artifactUrl, repo);
|
|
512
|
+
if (!asset) {
|
|
513
|
+
throw new Stop('check', `The build must be a file of a release of ${repo} (https://github.com/${repo}/releases/download/<tag>/<file>).`);
|
|
514
|
+
}
|
|
515
|
+
if (input.github && !input.github.token) {
|
|
516
|
+
throw new Stop('check', 'The GitHub connection has no token. Save it again under Settings › Connections.');
|
|
517
|
+
}
|
|
518
|
+
if (!/^[0-9a-f]{64}$/.test(app.artifactSha256)) {
|
|
519
|
+
throw new Stop('check', 'The build’s checksum in build.json is not a sha256 (64 lower-case hex characters).');
|
|
520
|
+
}
|
|
521
|
+
const stray = strayContainerImage(app, input.account.accountId);
|
|
522
|
+
if (stray) {
|
|
523
|
+
throw new Stop('check', `The ${stray.className} container image is not in your Cloudflare account’s registry. Push it to ${workerAppRegistryPrefix(input.account.accountId)}… from the app’s build, then try again.`);
|
|
524
|
+
}
|
|
525
|
+
const signIn = input.signIn ?? {};
|
|
526
|
+
for (const name of Object.keys(signIn)) {
|
|
527
|
+
if (!SIGN_IN_NAME.test(name) || !WORKER_APP_ENV_NAME.test(name)) {
|
|
528
|
+
throw new Stop('check', `${name.slice(0, 64)} is not a sign-in setting the app can take.`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
for (const name of Object.keys(input.secrets)) {
|
|
532
|
+
if (!WORKER_APP_ENV_NAME.test(name)) {
|
|
533
|
+
throw new Stop('check', `${name.slice(0, 64)} is not a secret name the app can take: capital letters, digits and _ only.`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const vars = { ...(app.vars ?? {}), ...signIn };
|
|
537
|
+
const clash = Object.keys(input.secrets).find((name) => name in vars);
|
|
538
|
+
if (clash)
|
|
539
|
+
throw new Stop('check', `${clash} is both a setting and a secret. Keep only one.`);
|
|
540
|
+
const missing = [];
|
|
541
|
+
for (const [name, source] of Object.entries(app.secrets ?? {})) {
|
|
542
|
+
if (!input.secrets[name])
|
|
543
|
+
missing.push(name);
|
|
544
|
+
if (source.from === 'keysCustody' &&
|
|
545
|
+
name === WORKER_APP_KEYS_SECRET &&
|
|
546
|
+
!vars[WORKER_APP_KEYS_URL_VAR]) {
|
|
547
|
+
missing.push(WORKER_APP_KEYS_URL_VAR);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (app.authEnv) {
|
|
551
|
+
for (const name of SIGN_IN_VARS)
|
|
552
|
+
if (!vars[name])
|
|
553
|
+
missing.push(name);
|
|
554
|
+
if (!input.secrets[AUTH_APP_KEY])
|
|
555
|
+
missing.push(AUTH_APP_KEY);
|
|
556
|
+
if (input.entry.uses?.auth?.mcp)
|
|
557
|
+
for (const name of MCP_VARS)
|
|
558
|
+
if (!vars[name])
|
|
559
|
+
missing.push(name);
|
|
560
|
+
}
|
|
561
|
+
if (missing.length > 0) {
|
|
562
|
+
throw new Stop('check', `The app still needs ${[...new Set(missing)].join(', ')}. Finish its sign-in and secrets in Boule, then try again.`);
|
|
563
|
+
}
|
|
564
|
+
let database = null;
|
|
565
|
+
if (app.database) {
|
|
566
|
+
if (!input.database?.url) {
|
|
567
|
+
throw new Stop('check', 'The app needs a database. Add its Postgres address under Database, then try again.');
|
|
568
|
+
}
|
|
569
|
+
database = parsePostgresUrl(input.database.url);
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
asset,
|
|
573
|
+
script: appScriptName(input.app.key, input.app.copy),
|
|
574
|
+
shape: workerAppShape(app),
|
|
575
|
+
vars: Object.entries(vars).sort(([a], [b]) => a.localeCompare(b)),
|
|
576
|
+
secrets: { ...input.secrets },
|
|
577
|
+
database,
|
|
578
|
+
migrationsNote: Boolean(app.database?.migrationsSha256),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
async function fetchBuild(ctx, prepared, github) {
|
|
582
|
+
const { shape, asset } = prepared;
|
|
583
|
+
const ms = ctx.deps.timeoutMs ?? CALL_TIMEOUT_MS;
|
|
584
|
+
const repo = `${asset.owner}/${asset.name}`;
|
|
585
|
+
let bytes;
|
|
586
|
+
try {
|
|
587
|
+
bytes = await withCallTimeout(ms, 'fetch the build', () => ctx.deps.fetchArtifact
|
|
588
|
+
? ctx.deps.fetchArtifact(shape.artifactUrl)
|
|
589
|
+
: fetchReleaseAsset(asset, {
|
|
590
|
+
token: github?.token ?? null,
|
|
591
|
+
fetch: ctx.deps.fetch ?? fetch,
|
|
592
|
+
timeoutMs: ms,
|
|
593
|
+
maxBytes: ARTIFACT_MAX_BYTES,
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
// `withCallTimeout` drops the error and keeps its code: ReleaseAssetError's reason.
|
|
598
|
+
const code = error?.code;
|
|
599
|
+
const reason = code === 'not_found' || code === 'refused' || code === 'too_large' ? code : 'failed';
|
|
600
|
+
if (reason === 'too_large')
|
|
601
|
+
throw new Stop('build', 'The app’s build is larger than 10 MB.');
|
|
602
|
+
if (reason === 'refused' || (reason === 'not_found' && !github)) {
|
|
603
|
+
throw new Stop('build', github
|
|
604
|
+
? `GitHub did not let us read the build of ${repo}. The GitHub token under Settings › Connections needs Contents: Read on ${repo}.`
|
|
605
|
+
: `We could not read the build of ${repo}. If the repository is private, connect GitHub under Settings › Connections, then try again.`);
|
|
606
|
+
}
|
|
607
|
+
if (reason === 'not_found') {
|
|
608
|
+
throw new Stop('build', `The release ${asset.tag} of ${repo} has no file ${asset.file}. Publish the release again with its files.`);
|
|
609
|
+
}
|
|
610
|
+
throw new Stop('build', 'We could not download the app’s build from its release. Check the GitHub connection and that the release still has its files.');
|
|
611
|
+
}
|
|
612
|
+
if (bytes.length === 0 || bytes.length > ARTIFACT_MAX_BYTES) {
|
|
613
|
+
throw new Stop('build', 'The app’s build is empty or larger than 10 MB.');
|
|
614
|
+
}
|
|
615
|
+
if (createHash('sha256').update(bytes).digest('hex') !== shape.artifactSha256.toLowerCase()) {
|
|
616
|
+
throw new Stop('build', 'The downloaded build is not the one build.json names (its checksum differs). Publish the release again.');
|
|
617
|
+
}
|
|
618
|
+
ctx.steps.push('Downloaded the build and checked it');
|
|
619
|
+
return bytes;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Deploys one Worker app copy into the company's own account and returns what it did, or the step
|
|
623
|
+
* that stopped it in plain words. Never throws.
|
|
624
|
+
*/
|
|
625
|
+
export async function deployWorkerApp(input, deps) {
|
|
626
|
+
const hidden = [];
|
|
627
|
+
let ctx = null;
|
|
628
|
+
try {
|
|
629
|
+
hidden.push(input.account.token);
|
|
630
|
+
hidden.push(...Object.values(input.secrets));
|
|
631
|
+
if (input.database?.url)
|
|
632
|
+
hidden.push(input.database.url);
|
|
633
|
+
if (input.github?.token)
|
|
634
|
+
hidden.push(input.github.token);
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
// An input we cannot read is refused by the checks below.
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
ctx = makeContext(input, deps);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
ctx = null;
|
|
644
|
+
}
|
|
645
|
+
return settle(async (c) => {
|
|
646
|
+
const prepared = prepare(input);
|
|
647
|
+
const { script, shape } = prepared;
|
|
648
|
+
if (prepared.database) {
|
|
649
|
+
const parsed = prepared.database;
|
|
650
|
+
hidden.push(parsed.password);
|
|
651
|
+
}
|
|
652
|
+
const address = await findAddress(c, input.app, script);
|
|
653
|
+
await checkAddressFree(c, address, script);
|
|
654
|
+
const module = await fetchBuild(c, prepared, input.github ?? null);
|
|
655
|
+
// The gate before the upload: a workers.dev address can be live the moment the script is,
|
|
656
|
+
// so a workspace is gated from its first request.
|
|
657
|
+
await ensureGate(c, address.host, script, input.gate ?? null);
|
|
658
|
+
let hyperdriveId;
|
|
659
|
+
if (prepared.database)
|
|
660
|
+
hyperdriveId = await ensureHyperdrive(c, script, prepared.database);
|
|
661
|
+
else if (input.database?.url)
|
|
662
|
+
c.steps.push('The database address was not used: this build asks for no database');
|
|
663
|
+
if (prepared.migrationsNote) {
|
|
664
|
+
c.steps.push('The build’s database changes (migrations.json) are not run by the deploy: run them first with applyWorkerAppMigrations');
|
|
665
|
+
}
|
|
666
|
+
const scripts = await c.call('upload', 'read your Workers', () => c.cf.listWorkerScripts());
|
|
667
|
+
const existing = scripts.find((s) => s.id === script);
|
|
668
|
+
const pending = pendingWorkerAppMigrations(shape.migrations, existing?.migrationTag);
|
|
669
|
+
if (!pending.ok) {
|
|
670
|
+
throw new Stop('upload', `The Worker ${script} in your account holds a Durable Object change this build does not list. Its build.json must keep every earlier migration, in order.`);
|
|
671
|
+
}
|
|
672
|
+
const upload = workerAppUpload(script, shape, prepared.vars, pending.migrations, hyperdriveId);
|
|
673
|
+
// A script already there takes its secrets before the new code that may read them (the
|
|
674
|
+
// upload keeps them); a new script cannot hold a secret before it exists.
|
|
675
|
+
if (existing)
|
|
676
|
+
await putSecrets(c, script, prepared.secrets);
|
|
677
|
+
await c.call('upload', 'upload the app', () => c.cf.uploadWorkerModule(script, { module, metadata: uploadMetadata(upload) }), deps.timeoutMs ?? UPLOAD_TIMEOUT_MS);
|
|
678
|
+
c.steps.push(`Uploaded ${script}${pending.migrations ? ` with Durable Object change ${pending.migrations.newTag}` : ''}`);
|
|
679
|
+
// On a company zone the route is the address and the gate is on it: workers.dev goes off
|
|
680
|
+
// at once, before anything else can fail, so nothing reaches the copy around them.
|
|
681
|
+
if (address.kind === 'zone') {
|
|
682
|
+
try {
|
|
683
|
+
await c.call('address', 'turn off the workers.dev address', () => c.cf.setWorkerScriptSubdomain(script, false));
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// The code is up and its gate is on the zone host only: workers.dev may serve it ungated.
|
|
687
|
+
throw new Stop('address', `${script} is uploaded, but Cloudflare did not let us turn off its workers.dev address, so it may be reachable there without its gate. Remove the app now (removeWorkerApp), then deploy it again.`, true);
|
|
688
|
+
}
|
|
689
|
+
c.steps.push(`Kept ${script} off workers.dev`);
|
|
690
|
+
}
|
|
691
|
+
if (!existing)
|
|
692
|
+
await putSecrets(c, script, prepared.secrets);
|
|
693
|
+
await pruneSecrets(c, script, prepared.secrets);
|
|
694
|
+
if (!prepared.database)
|
|
695
|
+
await deleteHyperdrive(c, 'database', script);
|
|
696
|
+
await syncContainers(c, script, shape.containers);
|
|
697
|
+
await publishAddress(c, address, script);
|
|
698
|
+
return `https://${address.host}`;
|
|
699
|
+
}, ctx, hidden);
|
|
700
|
+
}
|
|
701
|
+
/** Whether a route pattern (`host/*`, `*.zone/*`, `*host/*`) serves `host`. */
|
|
702
|
+
function routeCoversHost(pattern, host) {
|
|
703
|
+
const on = pattern.split('/')[0] ?? '';
|
|
704
|
+
if (on === host)
|
|
705
|
+
return true;
|
|
706
|
+
return on.startsWith('*') && host.endsWith(on.slice(1));
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Removes one copy: its workers.dev address first, then its route and DNS record, its containers,
|
|
710
|
+
* the script with its secrets, its database link, and its Access apps last, so the gate stays
|
|
711
|
+
* until nothing is left behind it. A piece already gone is skipped, so a rerun converges. Never
|
|
712
|
+
* throws.
|
|
713
|
+
*/
|
|
714
|
+
export async function removeWorkerApp(input, deps) {
|
|
715
|
+
let ctx = null;
|
|
716
|
+
try {
|
|
717
|
+
ctx = makeContext(input, deps);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
ctx = null;
|
|
721
|
+
}
|
|
722
|
+
return settle(async (c) => {
|
|
723
|
+
checkNames(input.app, true);
|
|
724
|
+
const script = appScriptName(input.app.key, input.app.copy);
|
|
725
|
+
const scripts = await c.call('remove', 'read your Workers', () => c.cf.listWorkerScripts());
|
|
726
|
+
const exists = scripts.some((s) => s.id === script);
|
|
727
|
+
if (exists) {
|
|
728
|
+
await c.call('address', 'turn off the workers.dev address', () => c.cf.setWorkerScriptSubdomain(script, false));
|
|
729
|
+
c.steps.push(`Turned off the workers.dev address of ${script}`);
|
|
730
|
+
}
|
|
731
|
+
let host = input.app.host;
|
|
732
|
+
if (onPlatformZone(input.app.host)) {
|
|
733
|
+
const subdomain = await c.call('address', 'read your workers.dev name', () => c.cf.getWorkersSubdomain());
|
|
734
|
+
if (subdomain)
|
|
735
|
+
host = `${script}.${subdomain}.workers.dev`;
|
|
736
|
+
else
|
|
737
|
+
c.steps.push('Your account has no workers.dev name, so there was no workers.dev address to remove');
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
const zone = await c.call('address', `find ${domainOf(input.app.host)} in your account`, () => c.cf.findZoneByApex(input.app.host));
|
|
741
|
+
if (!zone) {
|
|
742
|
+
c.steps.push(`${domainOf(input.app.host)} is not in your Cloudflare account, so there was no route to remove`);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
const routes = await c.call('address', `read the routes of ${zone.name}`, () => c.cf.listWorkerRoutes(zone.id));
|
|
746
|
+
for (const route of routes.filter((r) => r.script === script)) {
|
|
747
|
+
await c.call('address', `remove the route ${route.pattern}`, () => c.cf.deleteWorkerRoute(zone.id, route.id));
|
|
748
|
+
c.steps.push(`Removed the route ${route.pattern}`);
|
|
749
|
+
}
|
|
750
|
+
// The record may serve another Worker's route on the same name: then it stays.
|
|
751
|
+
const shared = routes.some((r) => r.script !== undefined &&
|
|
752
|
+
r.script !== script &&
|
|
753
|
+
routeCoversHost(r.pattern, input.app.host));
|
|
754
|
+
const records = await c.call('address', `read the DNS records of ${input.app.host}`, () => c.cf.listDnsRecords(zone.id, { name: input.app.host }));
|
|
755
|
+
for (const record of records) {
|
|
756
|
+
if (record.type !== WORKER_APP_DNS_RECORD.type ||
|
|
757
|
+
record.content !== WORKER_APP_DNS_RECORD.content) {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (shared) {
|
|
761
|
+
c.steps.push(`Kept the DNS record of ${input.app.host}: another Worker’s route uses it`);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
await c.call('address', `remove the DNS record of ${input.app.host}`, () => c.cf.deleteDnsRecord(zone.id, record.id));
|
|
765
|
+
c.steps.push(`Removed the DNS record of ${input.app.host}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const [namespaces, apps] = await Promise.all([
|
|
770
|
+
c.call('remove', 'read the app’s Durable Objects', () => c.cf.listDurableObjectNamespaces()),
|
|
771
|
+
c.call('remove', 'read your container applications', () => c.cf.listContainerApplications()),
|
|
772
|
+
]);
|
|
773
|
+
const ours = new Set(namespaces.filter((ns) => ns.script === script).map((ns) => ns.id));
|
|
774
|
+
for (const app of apps.filter((a) => ours.has(a.namespaceId))) {
|
|
775
|
+
await c.call('remove', `remove the container ${app.name}`, () => c.cf.deleteContainerApplication(app.id));
|
|
776
|
+
c.steps.push(`Removed the container ${app.name}`);
|
|
777
|
+
}
|
|
778
|
+
if (exists) {
|
|
779
|
+
await c.call('remove', `remove the Worker ${script}`, () => c.cf.deleteWorkerScript(script));
|
|
780
|
+
c.steps.push(`Removed the Worker ${script} and its secrets`);
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
c.steps.push(`The Worker ${script} was already gone`);
|
|
784
|
+
}
|
|
785
|
+
// After the script: Cloudflare refuses to delete a config a live Worker binds.
|
|
786
|
+
await deleteHyperdrive(c, 'database', script);
|
|
787
|
+
// Last: a copy that failed to go halfway is still behind its gate.
|
|
788
|
+
await ensureGate(c, host, script, null);
|
|
789
|
+
return `https://${host}`;
|
|
790
|
+
}, ctx, [input.account.token]);
|
|
791
|
+
}
|