@hyperfixation/cli 0.1.5 → 0.1.7
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/cli.d.ts +2 -2
- package/dist/cli.js +70 -13
- package/dist/cloud-steps/coolify.d.ts +6 -3
- package/dist/cloud-steps/coolify.js +10 -3
- package/dist/cloud-steps/deploy.d.ts +38 -1
- package/dist/cloud-steps/deploy.js +95 -31
- package/dist/cloud-steps/template.d.ts +2 -7
- package/dist/cloud-steps/template.js +12 -12
- package/dist/deploy-app.d.ts +32 -0
- package/dist/deploy-app.js +81 -0
- package/dist/doctor.d.ts +22 -1
- package/dist/doctor.js +151 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/new.d.ts +24 -0
- package/dist/new.js +38 -2
- package/dist/providers/coolify.d.ts +24 -0
- package/dist/providers/coolify.js +21 -0
- package/dist/restore-check.d.ts +33 -4
- package/dist/restore-check.js +71 -16
- package/dist/roles.d.ts +1 -0
- package/dist/roles.js +1 -1
- package/dist/state.d.ts +8 -0
- package/dist/state.js +1 -0
- package/dist/version.d.ts +8 -0
- package/dist/version.js +13 -0
- package/package.json +6 -6
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { deployCommit } from "./cloud-steps/deploy.js";
|
|
2
|
+
import { spawnStepExec, StepFailed } from "./cloud-steps/index.js";
|
|
3
|
+
import { gitAuthEnv } from "./cloud-steps/repo.js";
|
|
4
|
+
import { loadOperatorConfig, requireOperatorConfig } from "./config.js";
|
|
5
|
+
import { deriveNames } from "./names.js";
|
|
6
|
+
import { CoolifyClient } from "./providers/coolify.js";
|
|
7
|
+
import { openAppState } from "./state.js";
|
|
8
|
+
/** What a commit looks like once `git` has resolved it; `/api/status` reports the same form. */
|
|
9
|
+
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
10
|
+
/**
|
|
11
|
+
* `hf deploy <name>` — point the app at a commit and wait until it says it is serving it.
|
|
12
|
+
*
|
|
13
|
+
* The same path as `hf new`'s tenth step, and the reason there is a command at all: auto-deploy
|
|
14
|
+
* is off, so a merge to main changes nothing on the box until this runs. Everything it needs is
|
|
15
|
+
* in the state cache the provisioning run wrote; nothing here is interactive.
|
|
16
|
+
*/
|
|
17
|
+
export async function deployApp(options) {
|
|
18
|
+
const env = options.env ?? process.env;
|
|
19
|
+
const config = options.config ?? (await loadOperatorConfig({ env }));
|
|
20
|
+
const names = deriveNames(options.app);
|
|
21
|
+
const required = requireOperatorConfig(config, ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN", "HF_BASE_DOMAIN", "HF_GITHUB_TOKEN"], { env });
|
|
22
|
+
const store = await openAppState(names.given, { dir: options.stateDir, env });
|
|
23
|
+
const { coolify, statusTokens, repo } = store.state;
|
|
24
|
+
const appUuid = coolify?.appUuid;
|
|
25
|
+
const readToken = statusTokens?.read;
|
|
26
|
+
if (appUuid === undefined || readToken === undefined) {
|
|
27
|
+
const missing = appUuid === undefined ? "Coolify application uuid" : "read status token";
|
|
28
|
+
throw new StepFailed(`${store.file} has no ${missing}: hf new has not finished provisioning ${names.given}`);
|
|
29
|
+
}
|
|
30
|
+
const sha = options.sha === undefined
|
|
31
|
+
? await mainSha(options, repo, required.HF_GITHUB_TOKEN)
|
|
32
|
+
: options.sha;
|
|
33
|
+
if (!FULL_SHA.test(sha)) {
|
|
34
|
+
throw new StepFailed(`${sha} is not a commit sha: --sha takes the full forty hex characters, because that is ` +
|
|
35
|
+
"what /api/status reports back");
|
|
36
|
+
}
|
|
37
|
+
const fqdn = `${names.given}.${required.HF_BASE_DOMAIN}`;
|
|
38
|
+
await deployCommit({
|
|
39
|
+
name: names.given,
|
|
40
|
+
appUuid,
|
|
41
|
+
fqdn,
|
|
42
|
+
sha,
|
|
43
|
+
readToken,
|
|
44
|
+
coolify: new CoolifyClient({
|
|
45
|
+
url: required.HF_COOLIFY_URL,
|
|
46
|
+
token: required.HF_COOLIFY_TOKEN,
|
|
47
|
+
fetch: options.fetch,
|
|
48
|
+
}),
|
|
49
|
+
io: options.io,
|
|
50
|
+
now: options.now ?? (() => Date.now()),
|
|
51
|
+
sleep: options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms))),
|
|
52
|
+
fetch: options.fetch,
|
|
53
|
+
});
|
|
54
|
+
await store.patch({ lastDeployedSha: sha });
|
|
55
|
+
return { app: names.given, sha, url: `https://${fqdn}` };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `main`'s sha on the app's repository, read with `git ls-remote` and no checkout.
|
|
59
|
+
*
|
|
60
|
+
* The remote rather than a local clone: the operator running this has just merged a pull request,
|
|
61
|
+
* and whatever is in a directory on the laptop is not what the box would build.
|
|
62
|
+
*/
|
|
63
|
+
async function mainSha(options, repo, token) {
|
|
64
|
+
if (repo === undefined) {
|
|
65
|
+
throw new StepFailed(`no owner/name repository in ${options.app}'s state cache: nothing can be asked what main ` +
|
|
66
|
+
"is. Pass --sha <sha>.");
|
|
67
|
+
}
|
|
68
|
+
const url = `https://github.com/${repo}.git`;
|
|
69
|
+
const exec = options.exec ?? spawnStepExec;
|
|
70
|
+
const outcome = await exec("git", ["ls-remote", url, "refs/heads/main"], {
|
|
71
|
+
cwd: process.cwd(),
|
|
72
|
+
capture: true,
|
|
73
|
+
env: gitAuthEnv(token),
|
|
74
|
+
});
|
|
75
|
+
const sha = /^([0-9a-f]{40})\s/.exec(outcome.stdout.trim())?.[1];
|
|
76
|
+
if (outcome.code !== 0 || sha === undefined) {
|
|
77
|
+
throw new StepFailed(`git ls-remote ${url} refs/heads/main named no commit: the repository may be gone, the ` +
|
|
78
|
+
"branch unborn, or HF_GITHUB_TOKEN unable to read it. Pass --sha <sha>.");
|
|
79
|
+
}
|
|
80
|
+
return sha;
|
|
81
|
+
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { type OperatorConfig } from "./config.js";
|
|
2
|
+
import { type Database } from "./database.js";
|
|
2
3
|
import type { FetchLike } from "./providers/http.js";
|
|
3
4
|
import { type Runner } from "./runner.js";
|
|
4
5
|
/** A restore check older than this is a warning: E5 is meant to run weekly, not once. */
|
|
5
6
|
export declare const RESTORE_CHECK_MAX_AGE_DAYS = 7;
|
|
7
|
+
/** The `CONNECTION LIMIT` every application role is created with, in `provisionRoles`. */
|
|
8
|
+
export declare const APPLICATION_ROLE_CONNECTION_LIMIT = 25;
|
|
9
|
+
/** Past this share of `max_connections`, the next app to deploy is the one that cannot connect. */
|
|
10
|
+
export declare const CONNECTIONS_WARN_FRACTION = 0.8;
|
|
6
11
|
/** The branch prefix Phase 4's core bumps open their pull requests on. */
|
|
7
12
|
export declare const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
|
|
8
13
|
export type Severity = "ok" | "warn" | "fail";
|
|
9
14
|
export interface DoctorFinding {
|
|
10
15
|
/** The app as the state cache names it. */
|
|
11
16
|
app: string;
|
|
12
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* `state`, `status`, `runs`, `version`, `budget`, `E006`, `connections`, `lock`,
|
|
19
|
+
* `restore-check`, `core-bump`.
|
|
20
|
+
*/
|
|
13
21
|
check: string;
|
|
14
22
|
severity: Severity;
|
|
15
23
|
message: string;
|
|
@@ -39,6 +47,11 @@ export interface DoctorOptions {
|
|
|
39
47
|
now?: () => Date;
|
|
40
48
|
/** How E006 is read. Defaults to the tunnel to `HF_SSH_HOST` as `postgres`. */
|
|
41
49
|
privileges?: PrivilegeCheck;
|
|
50
|
+
/**
|
|
51
|
+
* Where the connection counts and the worker locks are read: the whole cluster, as the admin
|
|
52
|
+
* E006 already goes in as. Defaults to the same tunnel to `HF_SSH_HOST`.
|
|
53
|
+
*/
|
|
54
|
+
database?: () => Promise<Database>;
|
|
42
55
|
env?: NodeJS.ProcessEnv;
|
|
43
56
|
}
|
|
44
57
|
/**
|
|
@@ -70,3 +83,11 @@ export declare function tunnelPrivilegeCheck(runner: Runner, options?: {
|
|
|
70
83
|
* privilege query is a second thing to keep in step with the grants the migrator makes.
|
|
71
84
|
*/
|
|
72
85
|
export declare function checkAppRolePrivileges(adminUrl: string, role: string): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* The worker's advisory lock in one app's database: exactly one, under the key the worker takes.
|
|
88
|
+
*
|
|
89
|
+
* `pg_try_advisory_lock(bigint)` splits its key across `classid` and `objid`, so the key is
|
|
90
|
+
* reassembled rather than compared whole — and masked rather than only shifted, because
|
|
91
|
+
* `hashtext` answers `int4` and a negative hash widens to a bigint of sign bits.
|
|
92
|
+
*/
|
|
93
|
+
export declare function workerLockSql(appName: string): string;
|
package/dist/doctor.js
CHANGED
|
@@ -6,10 +6,15 @@ import { DEFAULT_PG_ADMIN_USER, loadOperatorConfig, pgAdminUser, postgresContain
|
|
|
6
6
|
import { openDatabase } from "./database.js";
|
|
7
7
|
import { deriveNames } from "./names.js";
|
|
8
8
|
import { GithubClient } from "./providers/github.js";
|
|
9
|
+
import { quoteLiteral } from "./roles.js";
|
|
9
10
|
import { createSshRunner } from "./runner.js";
|
|
10
11
|
import { openAppState, stateDir } from "./state.js";
|
|
11
12
|
/** A restore check older than this is a warning: E5 is meant to run weekly, not once. */
|
|
12
13
|
export const RESTORE_CHECK_MAX_AGE_DAYS = 7;
|
|
14
|
+
/** The `CONNECTION LIMIT` every application role is created with, in `provisionRoles`. */
|
|
15
|
+
export const APPLICATION_ROLE_CONNECTION_LIMIT = 25;
|
|
16
|
+
/** Past this share of `max_connections`, the next app to deploy is the one that cannot connect. */
|
|
17
|
+
export const CONNECTIONS_WARN_FRACTION = 0.8;
|
|
13
18
|
/** The branch prefix Phase 4's core bumps open their pull requests on. */
|
|
14
19
|
export const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
|
|
15
20
|
/**
|
|
@@ -27,6 +32,7 @@ export async function doctor(options = {}) {
|
|
|
27
32
|
const config = options.config ?? (await loadOperatorConfig({ env }));
|
|
28
33
|
const required = requireOperatorConfig(config, ["HF_BASE_DOMAIN", "HF_GITHUB_TOKEN"], { env });
|
|
29
34
|
const dir = options.stateDir ?? stateDir(env);
|
|
35
|
+
const cluster = lazyDatabase(options.database ?? defaultDatabase(config, env));
|
|
30
36
|
const context = {
|
|
31
37
|
dir,
|
|
32
38
|
env,
|
|
@@ -35,11 +41,20 @@ export async function doctor(options = {}) {
|
|
|
35
41
|
fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
36
42
|
now: options.now ?? (() => new Date()),
|
|
37
43
|
privileges: options.privileges ?? defaultPrivilegeCheck(config, env),
|
|
44
|
+
database: cluster.get,
|
|
45
|
+
// One snapshot for the whole run: the counts are the box's, not any one app's, and an app
|
|
46
|
+
// whose line is read a second later has not moved the cluster.
|
|
47
|
+
backends: once(async () => await readBackends(await cluster.get())),
|
|
38
48
|
};
|
|
39
49
|
const names = options.name === undefined ? await stateNames(dir) : [options.name];
|
|
40
50
|
const findings = [];
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
try {
|
|
52
|
+
for (const name of names)
|
|
53
|
+
findings.push(...(await doctorApp(context, name)));
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await cluster.close();
|
|
57
|
+
}
|
|
43
58
|
return { findings, ok: findings.every((finding) => finding.severity === "ok") };
|
|
44
59
|
}
|
|
45
60
|
const MARKER = { ok: "OK ", warn: "WARN", fail: "FAIL" };
|
|
@@ -109,6 +124,30 @@ function defaultPrivilegeCheck(config, env) {
|
|
|
109
124
|
adminUser: pgAdminUser(config),
|
|
110
125
|
});
|
|
111
126
|
}
|
|
127
|
+
function defaultDatabase(config, env) {
|
|
128
|
+
const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
|
|
129
|
+
const runner = createSshRunner({ host: HF_SSH_HOST });
|
|
130
|
+
return async () => await openDatabase(runner, {
|
|
131
|
+
admin: { user: pgAdminUser(config) },
|
|
132
|
+
containers: postgresContainers(config),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** One cluster connection for the whole run: opened when a check first needs it, closed once. */
|
|
136
|
+
function lazyDatabase(open) {
|
|
137
|
+
let pending;
|
|
138
|
+
return {
|
|
139
|
+
get: () => (pending ??= open()),
|
|
140
|
+
close: async () => {
|
|
141
|
+
// A run whose every app failed before the first query never opened one, and an open that
|
|
142
|
+
// failed is already a finding.
|
|
143
|
+
await pending?.then(async (db) => await db.close(), () => undefined);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function once(read) {
|
|
148
|
+
let pending;
|
|
149
|
+
return () => (pending ??= read());
|
|
150
|
+
}
|
|
112
151
|
async function doctorApp(context, name) {
|
|
113
152
|
const findings = [];
|
|
114
153
|
const add = (check, severity, message) => {
|
|
@@ -142,12 +181,19 @@ async function doctorApp(context, name) {
|
|
|
142
181
|
}
|
|
143
182
|
}
|
|
144
183
|
const report = await statusFindings(context, name, state, add);
|
|
145
|
-
versionFinding(report?.applicationVersion, mainSha, mainShaProblem, add);
|
|
184
|
+
versionFinding(name, report?.applicationVersion, mainSha, mainShaProblem, add);
|
|
146
185
|
if (report?.budget !== undefined) {
|
|
147
186
|
budgetFinding(report.budget.current, "current", add);
|
|
148
187
|
budgetFinding(report.budget.previous, "previous", add);
|
|
149
188
|
}
|
|
150
189
|
await privilegeFindings(context, name, add);
|
|
190
|
+
// A name `deriveNames` refuses has already failed E006 on that same message; the cluster checks
|
|
191
|
+
// have no names to run under and say nothing more.
|
|
192
|
+
const names = tryNames(name);
|
|
193
|
+
if (names !== undefined) {
|
|
194
|
+
await connectionFindings(context, names.applicationRole, add);
|
|
195
|
+
await lockFindings(context, names, add);
|
|
196
|
+
}
|
|
151
197
|
restoreCheckFindings(context, state, add);
|
|
152
198
|
if (repo !== undefined)
|
|
153
199
|
await bumpFindings(context, repo, add);
|
|
@@ -232,7 +278,14 @@ function amount(value) {
|
|
|
232
278
|
const parsed = Number(value);
|
|
233
279
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
234
280
|
}
|
|
235
|
-
|
|
281
|
+
/**
|
|
282
|
+
* What the app answers against what its repository's main holds.
|
|
283
|
+
*
|
|
284
|
+
* The mismatch names `hf deploy` because nothing else closes it: Coolify's push auto-deploy is
|
|
285
|
+
* disabled on every application `hf new` creates, so a merged pull request sits unpublished until
|
|
286
|
+
* an operator says so.
|
|
287
|
+
*/
|
|
288
|
+
function versionFinding(name, deployed, mainSha, mainShaProblem, add) {
|
|
236
289
|
if (mainShaProblem !== undefined) {
|
|
237
290
|
add("version", "fail", mainShaProblem);
|
|
238
291
|
return;
|
|
@@ -245,7 +298,7 @@ function versionFinding(deployed, mainSha, mainShaProblem, add) {
|
|
|
245
298
|
}
|
|
246
299
|
add("version", deployed === mainSha ? "ok" : "warn", deployed === mainSha
|
|
247
300
|
? `applicationVersion ${short(mainSha)} is main`
|
|
248
|
-
: `applicationVersion ${short(deployed)} is not main ${short(mainSha)}`);
|
|
301
|
+
: `applicationVersion ${short(deployed)} is not main ${short(mainSha)} — run hf deploy ${name}`);
|
|
249
302
|
}
|
|
250
303
|
function budgetFinding(period, which, add) {
|
|
251
304
|
if (period === null) {
|
|
@@ -287,6 +340,99 @@ async function privilegeFindings(context, name, add) {
|
|
|
287
340
|
add("E006", "fail", flatten(error.message));
|
|
288
341
|
}
|
|
289
342
|
}
|
|
343
|
+
/** Every backend belonging to an app role — `hf_<app>` and its `_migrator` and `_ro`. */
|
|
344
|
+
const BACKENDS_SQL = "SELECT usename, count(*) FROM pg_stat_activity WHERE usename LIKE 'hf\\_%' GROUP BY usename";
|
|
345
|
+
async function readBackends(db) {
|
|
346
|
+
const setting = (await db.query("SHOW max_connections")).rows[0]?.[0];
|
|
347
|
+
const byRole = new Map();
|
|
348
|
+
for (const row of (await db.query(BACKENDS_SQL)).rows) {
|
|
349
|
+
if (row[0] !== undefined)
|
|
350
|
+
byRole.set(row[0], Number(row[1]));
|
|
351
|
+
}
|
|
352
|
+
let total = 0;
|
|
353
|
+
for (const count of byRole.values())
|
|
354
|
+
total += count;
|
|
355
|
+
return { max: setting === undefined ? undefined : numeric(Number(setting)), total, byRole };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* What the box's connection slots are spent on, and what this app has of them.
|
|
359
|
+
*
|
|
360
|
+
* Box-wide rather than per-app because that is where it runs out: every app on the box draws on
|
|
361
|
+
* one `max_connections`, and the app that then cannot connect is whichever one deploys next.
|
|
362
|
+
*/
|
|
363
|
+
async function connectionFindings(context, role, add) {
|
|
364
|
+
let backends;
|
|
365
|
+
try {
|
|
366
|
+
backends = await context.backends();
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
add("connections", "fail", flatten(error.message));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const limit = String(APPLICATION_ROLE_CONNECTION_LIMIT);
|
|
373
|
+
const line = `${role} ${String(backends.byRole.get(role) ?? 0)}/${limit}, box ` +
|
|
374
|
+
`${String(backends.total)}/${backends.max === undefined ? UNKNOWN : String(backends.max)} ` +
|
|
375
|
+
"on hf_ roles";
|
|
376
|
+
const crowded = backends.max !== undefined && backends.total > backends.max * CONNECTIONS_WARN_FRACTION;
|
|
377
|
+
add("connections", crowded ? "warn" : "ok", crowded
|
|
378
|
+
? `${line} — over ${String(CONNECTIONS_WARN_FRACTION * 100)}% of max_connections`
|
|
379
|
+
: line);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* The worker's advisory lock in one app's database: exactly one, under the key the worker takes.
|
|
383
|
+
*
|
|
384
|
+
* `pg_try_advisory_lock(bigint)` splits its key across `classid` and `objid`, so the key is
|
|
385
|
+
* reassembled rather than compared whole — and masked rather than only shifted, because
|
|
386
|
+
* `hashtext` answers `int4` and a negative hash widens to a bigint of sign bits.
|
|
387
|
+
*/
|
|
388
|
+
export function workerLockSql(appName) {
|
|
389
|
+
return (`WITH k AS (SELECT hashtext('hf-worker:' || ${quoteLiteral(appName)})::bigint AS value) ` +
|
|
390
|
+
"SELECT count(l.pid), count(l.pid) FILTER (WHERE " +
|
|
391
|
+
"l.classid = ((k.value >> 32) & 4294967295)::oid AND " +
|
|
392
|
+
"l.objid = (k.value & 4294967295)::oid) " +
|
|
393
|
+
"FROM k LEFT JOIN pg_locks AS l ON l.locktype = 'advisory' AND " +
|
|
394
|
+
"l.database = (SELECT oid FROM pg_database WHERE datname = current_database())");
|
|
395
|
+
}
|
|
396
|
+
async function lockFindings(context, names, add) {
|
|
397
|
+
let row;
|
|
398
|
+
try {
|
|
399
|
+
const db = await context.database();
|
|
400
|
+
row = (await db.query(workerLockSql(names.appName), { database: names.databaseName })).rows[0];
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
add("lock", "fail", flatten(error.message));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const key = `hf-worker:${names.appName}`;
|
|
407
|
+
const held = Number(row?.[0]);
|
|
408
|
+
const matching = Number(row?.[1]);
|
|
409
|
+
if (!Number.isFinite(held)) {
|
|
410
|
+
add("lock", "fail", `pg_locks in ${names.databaseName} answered no count`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (held === 0) {
|
|
414
|
+
add("lock", "fail", `no advisory lock in ${names.databaseName}: no worker holds ${key}`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (held !== 1) {
|
|
418
|
+
add("lock", "fail", `${String(held)} advisory locks in ${names.databaseName}; one worker per app holds one`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (matching !== 1) {
|
|
422
|
+
add("lock", "fail", `the one advisory lock in ${names.databaseName} is not hashtext('${key}'): ` +
|
|
423
|
+
"something other than the worker holds it");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
add("lock", "ok", `one worker holds ${key} in ${names.databaseName}`);
|
|
427
|
+
}
|
|
428
|
+
function tryNames(name) {
|
|
429
|
+
try {
|
|
430
|
+
return deriveNames(name);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
290
436
|
function restoreCheckFindings(context, state, add) {
|
|
291
437
|
const last = state.lastRestoreCheckAt;
|
|
292
438
|
if (last === undefined) {
|
package/dist/index.d.ts
CHANGED
|
@@ -17,5 +17,5 @@ export { run, CommandFailed, type RunOptions } from "./spawn.js";
|
|
|
17
17
|
export { createLocalRunner, createSshRunner, shellQuote, sshExecArgv, sshTunnelArgv, RunnerError, DEFAULT_TUNNEL_READY_TIMEOUT_MS, type ExecOptions, type ExecResult, type LocalRunner, type LocalRunnerOptions, type Runner, type SshRunnerOptions, type Tunnel, } from "./runner.js";
|
|
18
18
|
export { findPostgresContainer, openDatabase, openDatabaseUrl, redactPasswords, DatabaseTransportError, DEFAULT_POSTGRES_PORT, type AdminCredentials, type Database, type DatabaseTransport, type OpenDatabaseOptions, type QueryOptions, type QueryResult, } from "./database.js";
|
|
19
19
|
export { createLocalDirectoryBackupSource, createS3BackupSource, BackupSourceError, COOLIFY_BACKUP_DIR, type BackupDump, type BackupSource, type BackupSourceKind, type LocalDirectoryBackupSourceOptions, } from "./backup-source.js";
|
|
20
|
-
export { formatRestoreCheck, pgRestoreArgv, pgRestoreInContainerArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, type RestoreCheckAppOptions, type RestoreCheckOptions, type RestoreCheckResult, type RestoreCheckRow, type RestoreVerdict, } from "./restore-check.js";
|
|
20
|
+
export { APPEND_ONLY_TABLES, formatRestoreCheck, pgRestoreArgv, pgRestoreInContainerArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, type RestoreCheckAppOptions, type RestoreCheckOptions, type RestoreCheckResult, type RestoreCheckRow, type RestoreVerdict, } from "./restore-check.js";
|
|
21
21
|
export { provisionDatabase, ProvisionDatabaseError, REQUIRED_EXTENSIONS, type ProvisionDatabaseOptions, type ProvisionDatabaseResult, } from "./provision-database.js";
|
package/dist/index.js
CHANGED
|
@@ -17,5 +17,5 @@ export { run, CommandFailed } from "./spawn.js";
|
|
|
17
17
|
export { createLocalRunner, createSshRunner, shellQuote, sshExecArgv, sshTunnelArgv, RunnerError, DEFAULT_TUNNEL_READY_TIMEOUT_MS, } from "./runner.js";
|
|
18
18
|
export { findPostgresContainer, openDatabase, openDatabaseUrl, redactPasswords, DatabaseTransportError, DEFAULT_POSTGRES_PORT, } from "./database.js";
|
|
19
19
|
export { createLocalDirectoryBackupSource, createS3BackupSource, BackupSourceError, COOLIFY_BACKUP_DIR, } from "./backup-source.js";
|
|
20
|
-
export { formatRestoreCheck, pgRestoreArgv, pgRestoreInContainerArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, } from "./restore-check.js";
|
|
20
|
+
export { APPEND_ONLY_TABLES, formatRestoreCheck, pgRestoreArgv, pgRestoreInContainerArgv, restoreCheck, restoreCheckApp, RestoreCheckError, SCRATCH_SUFFIX, STALE_DUMP_HOURS, } from "./restore-check.js";
|
|
21
21
|
export { provisionDatabase, ProvisionDatabaseError, REQUIRED_EXTENSIONS, } from "./provision-database.js";
|
package/dist/new.d.ts
CHANGED
|
@@ -14,6 +14,23 @@ export declare const EXCLUDED_ENTRIES: readonly string[];
|
|
|
14
14
|
export declare class TemplateError extends Error {
|
|
15
15
|
constructor(message: string);
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Where a cloud `hf new` fetches the template before it becomes the app.
|
|
19
|
+
*
|
|
20
|
+
* Beside the target rather than under `os.tmpdir()`, so the rename is a rename and not a second
|
|
21
|
+
* copy across filesystems, and dot-prefixed so a half-fetched tree does not look like an app.
|
|
22
|
+
*/
|
|
23
|
+
export declare function templateTempDir(dir: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* The two refusals a directory in the way earns, worded once because both halves of `hf new`
|
|
26
|
+
* raise them.
|
|
27
|
+
*
|
|
28
|
+
* Each names the absolute path and the single move that clears it: "already exists" alone leaves
|
|
29
|
+
* the operator to work out which of the app directory and the dot-prefixed scratch beside it is
|
|
30
|
+
* meant, and they are one keystroke apart.
|
|
31
|
+
*/
|
|
32
|
+
export declare function targetInTheWay(dir: string): TemplateError;
|
|
33
|
+
export declare function scratchInTheWay(scratch: string): TemplateError;
|
|
17
34
|
export interface NewAppOptions {
|
|
18
35
|
/** The name as typed; becomes the directory and, underscored, both placeholders. */
|
|
19
36
|
name: string;
|
|
@@ -29,6 +46,11 @@ export interface NewAppOptions {
|
|
|
29
46
|
local: boolean;
|
|
30
47
|
/** The bootstrap admin's address, written to `.env` as `HF_BOOTSTRAP_EMAIL`. Skips the prompt. */
|
|
31
48
|
email?: string;
|
|
49
|
+
/**
|
|
50
|
+
* The app's monthly LLM budget, written to `.env` as `HF_BOOTSTRAP_BUDGET_USD`. `hf up` seeds
|
|
51
|
+
* the app state from there instead of falling back to its dev default.
|
|
52
|
+
*/
|
|
53
|
+
budgetUsd?: string;
|
|
32
54
|
/** Overrides the real interactive prompt; for tests and other callers with their own stdin. */
|
|
33
55
|
promptEmail?: () => Promise<string>;
|
|
34
56
|
}
|
|
@@ -41,6 +63,8 @@ export interface NewAppResult extends AppNames {
|
|
|
41
63
|
wroteEnv: boolean;
|
|
42
64
|
/** True when `HF_BOOTSTRAP_EMAIL` was written to `.env`, from `--email` or the prompt. */
|
|
43
65
|
wroteBootstrapEmail: boolean;
|
|
66
|
+
/** True when `HF_BOOTSTRAP_BUDGET_USD` was written to `.env`, from `--budget-usd`. */
|
|
67
|
+
wroteBootstrapBudget: boolean;
|
|
44
68
|
}
|
|
45
69
|
/**
|
|
46
70
|
* Copies the template checkout and substitutes the two placeholders across it.
|
package/dist/new.js
CHANGED
|
@@ -46,6 +46,31 @@ export class TemplateError extends Error {
|
|
|
46
46
|
this.name = "TemplateError";
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Where a cloud `hf new` fetches the template before it becomes the app.
|
|
51
|
+
*
|
|
52
|
+
* Beside the target rather than under `os.tmpdir()`, so the rename is a rename and not a second
|
|
53
|
+
* copy across filesystems, and dot-prefixed so a half-fetched tree does not look like an app.
|
|
54
|
+
*/
|
|
55
|
+
export function templateTempDir(dir) {
|
|
56
|
+
return path.join(path.dirname(dir), `.${path.basename(dir)}.hf-new`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The two refusals a directory in the way earns, worded once because both halves of `hf new`
|
|
60
|
+
* raise them.
|
|
61
|
+
*
|
|
62
|
+
* Each names the absolute path and the single move that clears it: "already exists" alone leaves
|
|
63
|
+
* the operator to work out which of the app directory and the dot-prefixed scratch beside it is
|
|
64
|
+
* meant, and they are one keystroke apart.
|
|
65
|
+
*/
|
|
66
|
+
export function targetInTheWay(dir) {
|
|
67
|
+
return new TemplateError(`${dir} already exists; hf new will not write into it. Move it away (or delete it) and rerun — ` +
|
|
68
|
+
"the template is fetched into a directory of its own.");
|
|
69
|
+
}
|
|
70
|
+
export function scratchInTheWay(scratch) {
|
|
71
|
+
return new TemplateError(`${scratch} is a leftover hf new scratch directory and hf has no record of creating it. ` +
|
|
72
|
+
"Remove it and rerun; the template is fetched into it fresh.");
|
|
73
|
+
}
|
|
49
74
|
/**
|
|
50
75
|
* Copies the template checkout and substitutes the two placeholders across it.
|
|
51
76
|
*
|
|
@@ -64,7 +89,13 @@ export async function newApp(options) {
|
|
|
64
89
|
await assertTemplateSource(source);
|
|
65
90
|
const dir = path.resolve(options.into ?? process.cwd(), names.given);
|
|
66
91
|
if (await exists(dir)) {
|
|
67
|
-
throw
|
|
92
|
+
throw targetInTheWay(dir);
|
|
93
|
+
}
|
|
94
|
+
// A scratch directory here is an interrupted cloud run for this same name: copying an app over
|
|
95
|
+
// the top of it would leave that run's rerun to judge a directory neither flow made.
|
|
96
|
+
const scratch = templateTempDir(dir);
|
|
97
|
+
if (await exists(scratch)) {
|
|
98
|
+
throw scratchInTheWay(scratch);
|
|
68
99
|
}
|
|
69
100
|
await cp(source, dir, {
|
|
70
101
|
recursive: true,
|
|
@@ -75,6 +106,7 @@ export async function newApp(options) {
|
|
|
75
106
|
const example = path.join(dir, ".env.example");
|
|
76
107
|
const wroteEnv = await exists(example);
|
|
77
108
|
let wroteBootstrapEmail = false;
|
|
109
|
+
let wroteBootstrapBudget = false;
|
|
78
110
|
if (wroteEnv) {
|
|
79
111
|
let contents = await readFile(example, "utf8");
|
|
80
112
|
const email = options.email ?? (await (options.promptEmail ?? promptForBootstrapEmail)());
|
|
@@ -82,9 +114,13 @@ export async function newApp(options) {
|
|
|
82
114
|
contents = `${contents.trimEnd()}\nHF_BOOTSTRAP_EMAIL=${email}\n`;
|
|
83
115
|
wroteBootstrapEmail = true;
|
|
84
116
|
}
|
|
117
|
+
if (options.budgetUsd !== undefined && options.budgetUsd !== "") {
|
|
118
|
+
contents = `${contents.trimEnd()}\nHF_BOOTSTRAP_BUDGET_USD=${options.budgetUsd}\n`;
|
|
119
|
+
wroteBootstrapBudget = true;
|
|
120
|
+
}
|
|
85
121
|
await writeFile(path.join(dir, ".env"), contents);
|
|
86
122
|
}
|
|
87
|
-
return { ...names, dir, substituted, wroteEnv, wroteBootstrapEmail };
|
|
123
|
+
return { ...names, dir, substituted, wroteEnv, wroteBootstrapEmail, wroteBootstrapBudget };
|
|
88
124
|
}
|
|
89
125
|
/** The one-time prompt: the address `hf up` later hands `hf bootstrap` via `.env`. */
|
|
90
126
|
async function promptForBootstrapEmail() {
|
|
@@ -80,6 +80,23 @@ export interface CoolifyEnvironmentVariable {
|
|
|
80
80
|
is_literal?: boolean;
|
|
81
81
|
is_multiline?: boolean;
|
|
82
82
|
is_shown_once?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Whether the builder interpolates the variable, and whether the containers get it.
|
|
85
|
+
*
|
|
86
|
+
* Both default true in Coolify's UI and neither is in the document's *request* schemas —
|
|
87
|
+
* only in its `EnvironmentVariable` model — but the box accepts and stores them (verified
|
|
88
|
+
* against 4.3.21). `SOURCE_COMMIT` needs both: it is the compose image tag and build arg as
|
|
89
|
+
* well as the `HF_BUILD_SHA` three services read.
|
|
90
|
+
*/
|
|
91
|
+
is_buildtime?: boolean;
|
|
92
|
+
is_runtime?: boolean;
|
|
93
|
+
}
|
|
94
|
+
/** One entry of `GET /applications/{uuid}/envs`, narrowed to what an upsert has to match on. */
|
|
95
|
+
export interface CoolifyEnvEntry {
|
|
96
|
+
uuid: string;
|
|
97
|
+
key: string;
|
|
98
|
+
value?: string;
|
|
99
|
+
is_preview?: boolean;
|
|
83
100
|
}
|
|
84
101
|
export interface CoolifyDeploymentRequest {
|
|
85
102
|
deployments: {
|
|
@@ -171,6 +188,13 @@ export declare class CoolifyClient {
|
|
|
171
188
|
tag?: string;
|
|
172
189
|
}): Promise<CoolifyApplicationSummary[]>;
|
|
173
190
|
updateEnvsBulk(appUuid: string, data: readonly CoolifyEnvironmentVariable[]): Promise<unknown>;
|
|
191
|
+
/** Every environment variable on the application, preview and non-preview alike. */
|
|
192
|
+
listEnvs(appUuid: string): Promise<CoolifyEnvEntry[]>;
|
|
193
|
+
createEnv(appUuid: string, variable: CoolifyEnvironmentVariable): Promise<{
|
|
194
|
+
uuid: string;
|
|
195
|
+
}>;
|
|
196
|
+
/** Keyed by `key` — and by `is_preview`, which is why an upsert sends the entry's own flag. */
|
|
197
|
+
updateEnv(appUuid: string, variable: CoolifyEnvironmentVariable): Promise<unknown>;
|
|
174
198
|
deploy(uuid: string, options?: {
|
|
175
199
|
force?: boolean;
|
|
176
200
|
}): Promise<CoolifyDeploymentRequest>;
|
|
@@ -55,6 +55,27 @@ export class CoolifyClient {
|
|
|
55
55
|
secrets: data.map((variable) => variable.value),
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
+
/** Every environment variable on the application, preview and non-preview alike. */
|
|
59
|
+
async listEnvs(appUuid) {
|
|
60
|
+
return await this.request({ method: "GET", path: `/applications/${segment(appUuid)}/envs` });
|
|
61
|
+
}
|
|
62
|
+
async createEnv(appUuid, variable) {
|
|
63
|
+
return await this.request({
|
|
64
|
+
method: "POST",
|
|
65
|
+
path: `/applications/${segment(appUuid)}/envs`,
|
|
66
|
+
body: variable,
|
|
67
|
+
secrets: [variable.value],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Keyed by `key` — and by `is_preview`, which is why an upsert sends the entry's own flag. */
|
|
71
|
+
async updateEnv(appUuid, variable) {
|
|
72
|
+
return await this.request({
|
|
73
|
+
method: "PATCH",
|
|
74
|
+
path: `/applications/${segment(appUuid)}/envs`,
|
|
75
|
+
body: variable,
|
|
76
|
+
secrets: [variable.value],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
58
79
|
async deploy(uuid, options = {}) {
|
|
59
80
|
return await this.request({
|
|
60
81
|
method: "POST",
|
package/dist/restore-check.d.ts
CHANGED
|
@@ -4,8 +4,21 @@ import { type Runner } from "./runner.js";
|
|
|
4
4
|
import { type AppStateStore } from "./state.js";
|
|
5
5
|
/** Appended to `hf_<app>` for the database the dump is restored into and then dropped. */
|
|
6
6
|
export declare const SCRATCH_SUFFIX = "_restore_check";
|
|
7
|
-
/** Older than this and the dump gets a
|
|
8
|
-
export declare const STALE_DUMP_HOURS =
|
|
7
|
+
/** Older than this and the dump gets a WARN line, which — as in `hf doctor` — exits 1. */
|
|
8
|
+
export declare const STALE_DUMP_HOURS = 24;
|
|
9
|
+
/**
|
|
10
|
+
* Tables a live row is only ever added to, so a live count above the dump's is the app working,
|
|
11
|
+
* not lost data.
|
|
12
|
+
*
|
|
13
|
+
* X1 found this the hard way: against a 1.3-hour-old dump of a running app, 9 of 24 tables
|
|
14
|
+
* "mismatched" purely from churn since the dump, and the same check against a fresh dump matched
|
|
15
|
+
* all 24. A table earns a place here only when no code path deletes from it and none updates it
|
|
16
|
+
* in a way that lowers its count — checked against `packages/db/src/schema` and every statement
|
|
17
|
+
* in `core`, `workflows`, `auth` and `admin`. Anything else, including a table whose rows merely
|
|
18
|
+
* look permanent, stays exact: a false `ok` here hides exactly the data loss this command exists
|
|
19
|
+
* to catch.
|
|
20
|
+
*/
|
|
21
|
+
export declare const APPEND_ONLY_TABLES: readonly string[];
|
|
9
22
|
export type RestoreVerdict = "ok" | "mismatch" | "live only" | "restored only";
|
|
10
23
|
export interface RestoreCheckRow {
|
|
11
24
|
table: string;
|
|
@@ -13,6 +26,14 @@ export interface RestoreCheckRow {
|
|
|
13
26
|
live?: number;
|
|
14
27
|
/** Absent when the table is not in the restored dump. */
|
|
15
28
|
restored?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Rows the live side gained since the dump, on an `APPEND_ONLY_TABLES` table.
|
|
31
|
+
*
|
|
32
|
+
* Drift is not its own verdict because it is not its own outcome: the restore held everything
|
|
33
|
+
* the dump had, which is `ok`. The column prints it as `ok (drift +N)` so the operator can see
|
|
34
|
+
* why two counts differ without having to decide whether it mattered.
|
|
35
|
+
*/
|
|
36
|
+
drift?: number;
|
|
16
37
|
verdict: RestoreVerdict;
|
|
17
38
|
}
|
|
18
39
|
export interface RestoreCheckResult {
|
|
@@ -21,11 +42,15 @@ export interface RestoreCheckResult {
|
|
|
21
42
|
scratchDatabase: string;
|
|
22
43
|
dump: BackupDump;
|
|
23
44
|
dumpAgeHours: number;
|
|
24
|
-
/** The dump is older than `STALE_DUMP_HOURS
|
|
45
|
+
/** The dump is older than `STALE_DUMP_HOURS`; on its own enough to exit 1. */
|
|
25
46
|
dumpStale: boolean;
|
|
47
|
+
/** Exact matching was asked for, so no table was allowed to drift. */
|
|
48
|
+
strict: boolean;
|
|
26
49
|
/** One row per table, `hf_*` or carrying `normalized_name`, sorted by name. */
|
|
27
50
|
rows: readonly RestoreCheckRow[];
|
|
28
|
-
/** Every row's verdict is `ok
|
|
51
|
+
/** Every row's verdict is `ok`, drift included. What `lastRestoreCheckAt` is written on. */
|
|
52
|
+
matched: boolean;
|
|
53
|
+
/** `matched` and the dump is not stale. The command's exit code is `ok ? 0 : 1`. */
|
|
29
54
|
ok: boolean;
|
|
30
55
|
}
|
|
31
56
|
export declare class RestoreCheckError extends Error {
|
|
@@ -57,6 +82,8 @@ export interface RestoreCheckOptions {
|
|
|
57
82
|
restoreAdminUrl?: string;
|
|
58
83
|
/** The `pg_restore` binary inside the container. */
|
|
59
84
|
pgRestorePath?: string;
|
|
85
|
+
/** Compare every table exactly, `APPEND_ONLY_TABLES` included. */
|
|
86
|
+
strict?: boolean;
|
|
60
87
|
now?: Date;
|
|
61
88
|
}
|
|
62
89
|
/**
|
|
@@ -104,6 +131,8 @@ export interface RestoreCheckAppOptions {
|
|
|
104
131
|
backupDir?: string;
|
|
105
132
|
/** Read the dump from Hetzner object storage instead. Not implemented; see `backup-source`. */
|
|
106
133
|
fromS3?: boolean;
|
|
134
|
+
/** Compare every table exactly, `APPEND_ONLY_TABLES` included. */
|
|
135
|
+
strict?: boolean;
|
|
107
136
|
env?: NodeJS.ProcessEnv;
|
|
108
137
|
}
|
|
109
138
|
/**
|