@hyperfixation/cli 0.1.4 → 0.1.6
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 +25 -1
- package/dist/cloud-steps/backup.d.ts +12 -10
- package/dist/cloud-steps/backup.js +92 -16
- 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/config.d.ts +1 -1
- package/dist/config.js +5 -0
- package/dist/database.d.ts +20 -0
- package/dist/database.js +9 -10
- package/dist/deploy-app.d.ts +32 -0
- package/dist/deploy-app.js +81 -0
- package/dist/doctor.js +10 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/new-cloud.d.ts +4 -3
- package/dist/new-cloud.js +5 -3
- package/dist/providers/coolify.d.ts +56 -0
- package/dist/providers/coolify.js +38 -0
- package/dist/restore-check.d.ts +33 -8
- package/dist/restore-check.js +78 -34
- package/dist/runner.d.ts +9 -1
- package/dist/runner.js +26 -5
- package/package.json +6 -6
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export declare const COMMANDS: readonly ["new", "migrate", "bootstrap", "status-token", "check", "gen", "dev", "up", "doctor", "restore-check"];
|
|
1
|
+
export declare const COMMANDS: readonly ["new", "migrate", "bootstrap", "status-token", "check", "gen", "dev", "up", "deploy", "doctor", "restore-check"];
|
|
2
2
|
export type Command = (typeof COMMANDS)[number];
|
|
3
|
-
export declare const USAGE = "hf \u2014 the hyperfixation CLI\n\n hf new <name> provision the app in the cloud: fetch the template, push a private\n repo, register a backup, Sentry, Langfuse and DNS, create the\n database and its roles, create the Coolify application and its\n environment, deploy, and print what is left to do by hand.\n Resumable \u2014 a rerun repeats only what did not finish\n --budget-usd <amount> the app's monthly LLM budget (required; no default)\n --email <address> the bootstrap admin's address (required)\n --from <specifier> template to fetch (default: gh:grahamlutz/hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n\n hf new <name> --local copy the template into ./<name>, substitute its placeholders, and\n prompt for the bootstrap admin's email\n --from <dir> template checkout (default: the sibling hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n --email <address> the bootstrap admin's address; skips the prompt\n\n hf up install, infra, migrate, bootstrap, status tokens, then hf dev \u2014\n the whole local loop after hf new, safe to rerun; seeds a $10\n budget unless HF_BOOTSTRAP_BUDGET_USD is set in .env\n\n hf migrate create the application role, then run the app's migrate.ts\n --skip-roles the cloud path, where the roles already exist\n\n hf bootstrap grant the app its one bootstrap admin, and seed hf_app_state\n --email <address> the address to promote; otherwise HF_BOOTSTRAP_EMAIL\n --budget-usd <amount> the app's starting budget; otherwise HF_BOOTSTRAP_BUDGET_USD\n\n hf status-token provision /api/status's read and write tokens\n --read only the read token; refuses if it is already set\n --write only the write token; refuses if it is already set\n --rotate replace a token that is already set\n (no flags: fills in whichever of the two is unset)\n\n hf check declared env, pending migrations, and E001-E006\n\n hf doctor [name] every deployed app in the state cache, or one: /api/status under its\n read token, the deployed version against main, E006 as the app role,\n the last restore check, and open core-bump PRs. Exits 1 on any finding\n\n hf gen [generator] the app's turbo generators\n\n hf dev docker compose up, then pnpm dev under HF_BUILD_SHA=dev-<timestamp>\n --no-compose leave the dev infrastructure alone\n --compose-only bring the infrastructure up and stop\n\n hf restore-check <name> restore the newest hf_<name> dump beside the live database and\n compare row counts; exits 1 on any mismatch\n --backup-dir <dir> where the dumps are (default: Coolify's on the box)\n --from-s3 read the dump from object storage (not implemented)\n\nEvery command but `new`, `doctor` and `restore-check` runs against the app at or above the working directory, or --dir.\n";
|
|
3
|
+
export declare const USAGE = "hf \u2014 the hyperfixation CLI\n\n hf new <name> provision the app in the cloud: fetch the template, push a private\n repo, register a backup, Sentry, Langfuse and DNS, create the\n database and its roles, create the Coolify application and its\n environment, deploy, and print what is left to do by hand.\n Resumable \u2014 a rerun repeats only what did not finish\n --budget-usd <amount> the app's monthly LLM budget (required; no default)\n --email <address> the bootstrap admin's address (required)\n --from <specifier> template to fetch (default: gh:grahamlutz/hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n\n hf new <name> --local copy the template into ./<name>, substitute its placeholders, and\n prompt for the bootstrap admin's email\n --from <dir> template checkout (default: the sibling hyperfixation-template)\n --into <dir> where to create <name> (default: the working directory)\n --email <address> the bootstrap admin's address; skips the prompt\n\n hf up install, infra, migrate, bootstrap, status tokens, then hf dev \u2014\n the whole local loop after hf new, safe to rerun; seeds a $10\n budget unless HF_BOOTSTRAP_BUDGET_USD is set in .env\n\n hf migrate create the application role, then run the app's migrate.ts\n --skip-roles the cloud path, where the roles already exist\n\n hf bootstrap grant the app its one bootstrap admin, and seed hf_app_state\n --email <address> the address to promote; otherwise HF_BOOTSTRAP_EMAIL\n --budget-usd <amount> the app's starting budget; otherwise HF_BOOTSTRAP_BUDGET_USD\n\n hf status-token provision /api/status's read and write tokens\n --read only the read token; refuses if it is already set\n --write only the write token; refuses if it is already set\n --rotate replace a token that is already set\n (no flags: fills in whichever of the two is unset)\n\n hf check declared env, pending migrations, and E001-E006\n\n hf deploy <name> set SOURCE_COMMIT to a commit, deploy it, and wait until /api/status\n reports it. Coolify's push auto-deploy is off, so this is what\n publishes a merge to main\n --sha <sha> the commit to deploy (default: main's, via git ls-remote)\n\n hf doctor [name] every deployed app in the state cache, or one: /api/status under its\n read token, the deployed version against main, E006 as the app role,\n the last restore check, and open core-bump PRs. Exits 1 on any finding\n\n hf gen [generator] the app's turbo generators\n\n hf dev docker compose up, then pnpm dev under HF_BUILD_SHA=dev-<timestamp>\n --no-compose leave the dev infrastructure alone\n --compose-only bring the infrastructure up and stop\n\n hf restore-check <name> restore the newest hf_<name> dump beside the live database and\n compare row counts; exits 1 on any mismatch\n --backup-dir <dir> where the dumps are (default: Coolify's on the box)\n --from-s3 read the dump from object storage (not implemented)\n\nEvery command but `new`, `deploy`, `doctor` and `restore-check` runs against the app at or above the working directory, or --dir.\n";
|
|
4
4
|
export interface Io {
|
|
5
5
|
out(line: string): void;
|
|
6
6
|
err(line: string): void;
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { bootstrapApp } from "./bootstrap.js";
|
|
3
3
|
import { checkApp } from "./check.js";
|
|
4
|
+
import { deployApp } from "./deploy-app.js";
|
|
4
5
|
import { dev, devBuildSha } from "./dev.js";
|
|
5
6
|
import { doctor, doctorLines } from "./doctor.js";
|
|
6
7
|
import { generate } from "./gen.js";
|
|
@@ -20,6 +21,7 @@ export const COMMANDS = [
|
|
|
20
21
|
"gen",
|
|
21
22
|
"dev",
|
|
22
23
|
"up",
|
|
24
|
+
"deploy",
|
|
23
25
|
"doctor",
|
|
24
26
|
"restore-check",
|
|
25
27
|
];
|
|
@@ -60,6 +62,11 @@ export const USAGE = `hf — the hyperfixation CLI
|
|
|
60
62
|
|
|
61
63
|
hf check declared env, pending migrations, and E001-E006
|
|
62
64
|
|
|
65
|
+
hf deploy <name> set SOURCE_COMMIT to a commit, deploy it, and wait until /api/status
|
|
66
|
+
reports it. Coolify's push auto-deploy is off, so this is what
|
|
67
|
+
publishes a merge to main
|
|
68
|
+
--sha <sha> the commit to deploy (default: main's, via git ls-remote)
|
|
69
|
+
|
|
63
70
|
hf doctor [name] every deployed app in the state cache, or one: /api/status under its
|
|
64
71
|
read token, the deployed version against main, E006 as the app role,
|
|
65
72
|
the last restore check, and open core-bump PRs. Exits 1 on any finding
|
|
@@ -75,7 +82,7 @@ export const USAGE = `hf — the hyperfixation CLI
|
|
|
75
82
|
--backup-dir <dir> where the dumps are (default: Coolify's on the box)
|
|
76
83
|
--from-s3 read the dump from object storage (not implemented)
|
|
77
84
|
|
|
78
|
-
Every command but \`new\`, \`doctor\` and \`restore-check\` runs against the app at or above the working directory, or --dir.
|
|
85
|
+
Every command but \`new\`, \`deploy\`, \`doctor\` and \`restore-check\` runs against the app at or above the working directory, or --dir.
|
|
79
86
|
`;
|
|
80
87
|
const consoleIo = {
|
|
81
88
|
out: (line) => console.log(line),
|
|
@@ -125,6 +132,8 @@ async function dispatch(command, argv, io) {
|
|
|
125
132
|
return await commandDev(argv, io);
|
|
126
133
|
case "up":
|
|
127
134
|
return await commandUp(argv, io);
|
|
135
|
+
case "deploy":
|
|
136
|
+
return await commandDeploy(argv, io);
|
|
128
137
|
case "doctor":
|
|
129
138
|
return await commandDoctor(argv, io);
|
|
130
139
|
case "restore-check":
|
|
@@ -275,6 +284,21 @@ async function commandCheck(argv, io) {
|
|
|
275
284
|
io.err(`${finding.code}: ${finding.message}`);
|
|
276
285
|
return 1;
|
|
277
286
|
}
|
|
287
|
+
async function commandDeploy(argv, io) {
|
|
288
|
+
const { values, positionals } = parseArgs({
|
|
289
|
+
args: [...argv],
|
|
290
|
+
options: { sha: { type: "string" } },
|
|
291
|
+
allowPositionals: true,
|
|
292
|
+
});
|
|
293
|
+
const name = positionals[0];
|
|
294
|
+
if (name === undefined) {
|
|
295
|
+
io.err("hf deploy needs a name: hf deploy <name> [--sha <sha>]");
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
// Every progress line the deploy prints — including the last one — is the command's output.
|
|
299
|
+
await deployApp({ app: name, sha: values.sha, io });
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
278
302
|
async function commandDoctor(argv, io) {
|
|
279
303
|
const { positionals } = parseArgs({ args: [...argv], allowPositionals: true });
|
|
280
304
|
const result = await doctor({ name: positionals[0] });
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import type { Step } from "../new-cloud.js";
|
|
2
2
|
import type { CloudStepContext } from "./context.js";
|
|
3
3
|
/**
|
|
4
|
-
* A daily dump of the app's database,
|
|
5
|
-
* answer.
|
|
4
|
+
* A daily dump of the app's database, off the box.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* because
|
|
6
|
+
* Coolify accepts `save_s3: true` with no `s3_storage_uuid`, runs the backup, logs `S3 storage
|
|
7
|
+
* configuration is missing or has been deleted (S3 storage ID: null). S3 backup has been disabled`
|
|
8
|
+
* and keeps the only copy on the same disk as the database — which is not a backup. So the storage
|
|
9
|
+
* is resolved first: `HF_COOLIFY_S3_STORAGE_UUID`, or the one usable storage on the box when there
|
|
10
|
+
* is exactly one. None or several and the step does not guess: it registers the schedule
|
|
11
|
+
* `save_s3: false` and says so in a warning and the closing checklist, because a schedule that is
|
|
12
|
+
* honestly local-only beats one that looks remote in the UI and is not.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* A rerun reconciles rather than duplicates: the schedules are listed and the one for this app's
|
|
15
|
+
* database is PATCHed. Coolify documents that list as "Content is very complex. Will be implemented
|
|
16
|
+
* later.", so the response is narrowed by hand and anything unreadable falls back to the old
|
|
17
|
+
* behaviour — register, and say out loud that a duplicate is possible.
|
|
16
18
|
*/
|
|
17
19
|
export declare const backupStep: Step<CloudStepContext>;
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { requireOperatorConfig } from "../config.js";
|
|
2
2
|
import { CoolifyClient } from "../providers/coolify.js";
|
|
3
|
+
import { ProviderError } from "../providers/http.js";
|
|
3
4
|
/**
|
|
4
|
-
* A daily dump of the app's database,
|
|
5
|
-
* answer.
|
|
5
|
+
* A daily dump of the app's database, off the box.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* because
|
|
7
|
+
* Coolify accepts `save_s3: true` with no `s3_storage_uuid`, runs the backup, logs `S3 storage
|
|
8
|
+
* configuration is missing or has been deleted (S3 storage ID: null). S3 backup has been disabled`
|
|
9
|
+
* and keeps the only copy on the same disk as the database — which is not a backup. So the storage
|
|
10
|
+
* is resolved first: `HF_COOLIFY_S3_STORAGE_UUID`, or the one usable storage on the box when there
|
|
11
|
+
* is exactly one. None or several and the step does not guess: it registers the schedule
|
|
12
|
+
* `save_s3: false` and says so in a warning and the closing checklist, because a schedule that is
|
|
13
|
+
* honestly local-only beats one that looks remote in the UI and is not.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* A rerun reconciles rather than duplicates: the schedules are listed and the one for this app's
|
|
16
|
+
* database is PATCHed. Coolify documents that list as "Content is very complex. Will be implemented
|
|
17
|
+
* later.", so the response is narrowed by hand and anything unreadable falls back to the old
|
|
18
|
+
* behaviour — register, and say out loud that a duplicate is possible.
|
|
16
19
|
*/
|
|
17
20
|
export const backupStep = {
|
|
18
21
|
name: "backup",
|
|
@@ -24,17 +27,90 @@ export const backupStep = {
|
|
|
24
27
|
token: required.HF_COOLIFY_TOKEN,
|
|
25
28
|
fetch: context.fetch,
|
|
26
29
|
});
|
|
27
|
-
await
|
|
30
|
+
const storage = await resolveStorage(context, coolify);
|
|
31
|
+
const schedule = {
|
|
28
32
|
frequency: "daily",
|
|
29
33
|
enabled: true,
|
|
30
34
|
// This app's database alone: `dump_all` would put every app on the cluster in one dump, and
|
|
31
35
|
// E5 restores one database at a time.
|
|
32
36
|
databases_to_backup: names.databaseName,
|
|
33
37
|
dump_all: false,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
...(storage === undefined
|
|
39
|
+
? { save_s3: false }
|
|
40
|
+
: { save_s3: true, s3_storage_uuid: storage }),
|
|
41
|
+
};
|
|
42
|
+
const where = storage === undefined ? "on the box only" : "to S3";
|
|
43
|
+
const database = required.HF_COOLIFY_POSTGRES_UUID;
|
|
44
|
+
const existing = await existingSchedule(coolify, database, names.databaseName);
|
|
45
|
+
if (typeof existing === "object") {
|
|
46
|
+
await coolify.updateDatabaseBackup(database, existing.uuid, schedule);
|
|
47
|
+
context.io.out(`${names.given}: updated the daily backup of ${names.databaseName} ${where}, ` +
|
|
48
|
+
`the schedule an earlier run registered`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await coolify.createDatabaseBackup(database, { ...schedule, backup_now: false });
|
|
52
|
+
context.io.out(`${names.given}: registered a daily backup of ${names.databaseName} ${where}`);
|
|
53
|
+
if (existing === "unreadable") {
|
|
54
|
+
context.checklist.push(`check Coolify for a second backup schedule for ${names.databaseName} — its backups list ` +
|
|
55
|
+
`could not be read, so an earlier run may have left one`);
|
|
56
|
+
}
|
|
39
57
|
},
|
|
40
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* The S3 storage the dump goes to, or `undefined` with the reason said out loud.
|
|
61
|
+
*
|
|
62
|
+
* The config key wins outright and is not checked against the list: an operator who named a uuid
|
|
63
|
+
* has answered the question, and a `GET` that disagrees would only be a second opinion about a
|
|
64
|
+
* storage Coolify itself will validate.
|
|
65
|
+
*/
|
|
66
|
+
async function resolveStorage(context, coolify) {
|
|
67
|
+
const configured = context.config.HF_COOLIFY_S3_STORAGE_UUID;
|
|
68
|
+
if (configured !== undefined && configured !== "")
|
|
69
|
+
return configured;
|
|
70
|
+
const usable = (await coolify.listS3Storages()).filter((storage) => storage.is_usable === true);
|
|
71
|
+
if (usable.length === 1)
|
|
72
|
+
return usable[0].uuid;
|
|
73
|
+
const note = localOnlyNote(context.names.databaseName, usable);
|
|
74
|
+
context.io.out(`WARNING: ${context.names.given}: ${note}`);
|
|
75
|
+
context.checklist.push(note);
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
/** What is left to the operator when the step will not pick a storage for them. */
|
|
79
|
+
function localOnlyNote(databaseName, usable) {
|
|
80
|
+
const problem = usable.length === 0
|
|
81
|
+
? "Coolify has no usable S3 storage"
|
|
82
|
+
: `Coolify has ${String(usable.length)} usable S3 storages (${usable
|
|
83
|
+
.map((storage) => `${storage.name} (${storage.uuid})`)
|
|
84
|
+
.join(", ")}) and hf will not choose between them`;
|
|
85
|
+
return (`${databaseName}'s daily backup is local-only — every copy sits on the same box as the ` +
|
|
86
|
+
`database, so losing the box loses the data. ${problem}. Set HF_COOLIFY_S3_STORAGE_UUID to ` +
|
|
87
|
+
`the storage to upload to (Coolify → Storages → the S3 storage → uuid in the URL), adding ` +
|
|
88
|
+
`one there first if there is none, and re-run hf new.`);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* This app's existing schedule, `"none"` for a list that has none, and `"unreadable"` for a list
|
|
92
|
+
* this cannot narrow — a shape upstream does not document, or a box that refuses the request.
|
|
93
|
+
*/
|
|
94
|
+
async function existingSchedule(coolify, databaseUuid, databaseName) {
|
|
95
|
+
let listed;
|
|
96
|
+
try {
|
|
97
|
+
listed = await coolify.listDatabaseBackups(databaseUuid);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!(error instanceof ProviderError))
|
|
101
|
+
throw error;
|
|
102
|
+
return "unreadable";
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(listed))
|
|
105
|
+
return "unreadable";
|
|
106
|
+
for (const entry of listed) {
|
|
107
|
+
if (typeof entry !== "object" || entry === null)
|
|
108
|
+
return "unreadable";
|
|
109
|
+
const { uuid, databases_to_backup } = entry;
|
|
110
|
+
if (typeof uuid !== "string" || typeof databases_to_backup !== "string")
|
|
111
|
+
return "unreadable";
|
|
112
|
+
if (databases_to_backup === databaseName)
|
|
113
|
+
return { uuid };
|
|
114
|
+
}
|
|
115
|
+
return "none";
|
|
116
|
+
}
|
|
@@ -19,9 +19,12 @@ export declare const COMPOSE_DOMAIN_SERVICE = "web";
|
|
|
19
19
|
/** The compose file the drift assertion reads, under the app's directory. */
|
|
20
20
|
export declare const PROD_COMPOSE_FILE = "docker-compose.prod.yml";
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Interpolated by the compose file but never part of this step's bulk PATCH.
|
|
23
|
+
*
|
|
24
|
+
* `DOCKER_IMAGE` has a default in the `x-app` anchor. `SOURCE_COMMIT` is written by the `deploy`
|
|
25
|
+
* step instead, once per deployment and against the commit being deployed — it is not one of the
|
|
26
|
+
* app's secrets, so it belongs neither in `secretsHash` nor in a PATCH that a rotation replays.
|
|
27
|
+
* Both are excused here rather than counted as drift.
|
|
25
28
|
*/
|
|
26
29
|
export declare const COMPOSE_PROVIDED_ENV: readonly ["DOCKER_IMAGE", "SOURCE_COMMIT"];
|
|
27
30
|
/** Written literally into every `environment:` block, so the deploy never carries them. */
|
|
@@ -22,9 +22,12 @@ export const COMPOSE_DOMAIN_SERVICE = "web";
|
|
|
22
22
|
/** The compose file the drift assertion reads, under the app's directory. */
|
|
23
23
|
export const PROD_COMPOSE_FILE = "docker-compose.prod.yml";
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Interpolated by the compose file but never part of this step's bulk PATCH.
|
|
26
|
+
*
|
|
27
|
+
* `DOCKER_IMAGE` has a default in the `x-app` anchor. `SOURCE_COMMIT` is written by the `deploy`
|
|
28
|
+
* step instead, once per deployment and against the commit being deployed — it is not one of the
|
|
29
|
+
* app's secrets, so it belongs neither in `secretsHash` nor in a PATCH that a rotation replays.
|
|
30
|
+
* Both are excused here rather than counted as drift.
|
|
28
31
|
*/
|
|
29
32
|
export const COMPOSE_PROVIDED_ENV = ["DOCKER_IMAGE", "SOURCE_COMMIT"];
|
|
30
33
|
/** Written literally into every `environment:` block, so the deploy never carries them. */
|
|
@@ -267,6 +270,10 @@ async function findOrCreateApplication(context, coolify, projectUuid, environmen
|
|
|
267
270
|
docker_compose_domains: [{ name: COMPOSE_DOMAIN_SERVICE, domain: `https://${fqdn}` }],
|
|
268
271
|
// The `deploy` step is what deploys, once the environment is set and the database migrated.
|
|
269
272
|
instant_deploy: false,
|
|
273
|
+
// Coolify's default is true: a push to main would then deploy whatever arrived against
|
|
274
|
+
// whatever `SOURCE_COMMIT` happened to hold, and the app would report a version it is not
|
|
275
|
+
// running. Every deploy goes through `hf` — `hf new`'s tenth step, or `hf deploy <name>`.
|
|
276
|
+
is_auto_deploy_enabled: false,
|
|
270
277
|
});
|
|
271
278
|
await context.state.patch({ coolify: { appUuid: created.uuid } });
|
|
272
279
|
context.io.out(`${name}: created the Coolify application at https://${fqdn}`);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Step } from "../new-cloud.js";
|
|
2
|
-
import {
|
|
2
|
+
import { CoolifyClient } from "../providers/coolify.js";
|
|
3
|
+
import type { FetchLike } from "../providers/http.js";
|
|
4
|
+
import { type CloudStepContext, type StepOut } from "./context.js";
|
|
3
5
|
/**
|
|
4
6
|
* How long the step waits for Coolify to build and for the app to report the sha it built.
|
|
5
7
|
*
|
|
@@ -7,6 +9,41 @@ import { type CloudStepContext } from "./context.js";
|
|
|
7
9
|
* the right version, and a build that took fourteen minutes has not left time for anything else.
|
|
8
10
|
*/
|
|
9
11
|
export declare const DEPLOY_TIMEOUT_MS: number;
|
|
12
|
+
/**
|
|
13
|
+
* The compose variable that carries the deployed commit, and the one channel there is for it.
|
|
14
|
+
*
|
|
15
|
+
* Coolify's docker-compose build pack passes the commit into nothing: no `--build-arg`, nothing
|
|
16
|
+
* in the compose environment, and no `.git` in the build context even with
|
|
17
|
+
* `is_preserve_repository_enabled` — so the Dockerfile's `git rev-parse HEAD` fallback cannot run
|
|
18
|
+
* there either, and the worker dies at startup on an unresolved `HF_BUILD_SHA` (verified on the
|
|
19
|
+
* box, 4.3.21). What Coolify *does* do is materialise an application environment entry for every
|
|
20
|
+
* `${VAR}` the compose file interpolates, which is what this fills in before every deploy.
|
|
21
|
+
*/
|
|
22
|
+
export declare const SOURCE_COMMIT_ENV = "SOURCE_COMMIT";
|
|
23
|
+
/** One deployment of one commit: everything `deployCommit` needs that a step context also has. */
|
|
24
|
+
export interface DeployTarget {
|
|
25
|
+
/** The app as the state cache names it — the prefix on every line this prints. */
|
|
26
|
+
name: string;
|
|
27
|
+
appUuid: string;
|
|
28
|
+
fqdn: string;
|
|
29
|
+
/** The exact commit being deployed; `/api/status` has to report this one. */
|
|
30
|
+
sha: string;
|
|
31
|
+
/** `/api/status`'s read token, which is the only way to ask what the app is running. */
|
|
32
|
+
readToken: string;
|
|
33
|
+
coolify: CoolifyClient;
|
|
34
|
+
io: StepOut;
|
|
35
|
+
now(): number;
|
|
36
|
+
sleep(ms: number): Promise<void>;
|
|
37
|
+
fetch?: FetchLike;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `SOURCE_COMMIT`, then the deploy, then the wait — in that order and never another.
|
|
41
|
+
*
|
|
42
|
+
* The environment write is what the build reads, so a deploy triggered first would build the
|
|
43
|
+
* previous commit's tag and the wait would then time out against a version the app is right to
|
|
44
|
+
* report. Both `hf new`'s tenth step and `hf deploy` come through here for that reason.
|
|
45
|
+
*/
|
|
46
|
+
export declare function deployCommit(target: DeployTarget): Promise<void>;
|
|
10
47
|
/**
|
|
11
48
|
* Deploy, wait for the build, then wait for the app to say it is running that commit.
|
|
12
49
|
*
|
|
@@ -12,6 +12,37 @@ const FIRST_POLL_MS = 2_000;
|
|
|
12
12
|
const MAX_POLL_MS = 15_000;
|
|
13
13
|
/** Coolify's one terminal success; `failed` and `cancelled-*` are the terminal failures. */
|
|
14
14
|
const FINISHED = "finished";
|
|
15
|
+
/**
|
|
16
|
+
* The compose variable that carries the deployed commit, and the one channel there is for it.
|
|
17
|
+
*
|
|
18
|
+
* Coolify's docker-compose build pack passes the commit into nothing: no `--build-arg`, nothing
|
|
19
|
+
* in the compose environment, and no `.git` in the build context even with
|
|
20
|
+
* `is_preserve_repository_enabled` — so the Dockerfile's `git rev-parse HEAD` fallback cannot run
|
|
21
|
+
* there either, and the worker dies at startup on an unresolved `HF_BUILD_SHA` (verified on the
|
|
22
|
+
* box, 4.3.21). What Coolify *does* do is materialise an application environment entry for every
|
|
23
|
+
* `${VAR}` the compose file interpolates, which is what this fills in before every deploy.
|
|
24
|
+
*/
|
|
25
|
+
export const SOURCE_COMMIT_ENV = "SOURCE_COMMIT";
|
|
26
|
+
/**
|
|
27
|
+
* `SOURCE_COMMIT`, then the deploy, then the wait — in that order and never another.
|
|
28
|
+
*
|
|
29
|
+
* The environment write is what the build reads, so a deploy triggered first would build the
|
|
30
|
+
* previous commit's tag and the wait would then time out against a version the app is right to
|
|
31
|
+
* report. Both `hf new`'s tenth step and `hf deploy` come through here for that reason.
|
|
32
|
+
*/
|
|
33
|
+
export async function deployCommit(target) {
|
|
34
|
+
const deadline = target.now() + DEPLOY_TIMEOUT_MS;
|
|
35
|
+
await setSourceCommit(target);
|
|
36
|
+
const { deployments } = await target.coolify.deploy(target.appUuid, { force: true });
|
|
37
|
+
const deploymentUuid = deployments[0]?.deployment_uuid;
|
|
38
|
+
if (deploymentUuid === undefined) {
|
|
39
|
+
throw new StepFailed(`Coolify accepted the deploy of ${target.name} but named no deployment`);
|
|
40
|
+
}
|
|
41
|
+
target.io.out(`${target.name}: deployment ${deploymentUuid} queued`);
|
|
42
|
+
await waitForBuild(target, deploymentUuid, deadline);
|
|
43
|
+
await waitForVersion(target, deadline);
|
|
44
|
+
target.io.out(`${target.name}: serving ${short(target.sha)} at https://${target.fqdn}`);
|
|
45
|
+
}
|
|
15
46
|
/**
|
|
16
47
|
* Deploy, wait for the build, then wait for the app to say it is running that commit.
|
|
17
48
|
*
|
|
@@ -32,59 +63,92 @@ export const deployStep = {
|
|
|
32
63
|
if (sha === undefined) {
|
|
33
64
|
throw new StepFailed(`${context.dir} has no commit to deploy: the install step has not run`);
|
|
34
65
|
}
|
|
66
|
+
const fqdn = appFqdn(context);
|
|
67
|
+
const readToken = context.state.state.statusTokens?.read;
|
|
68
|
+
if (readToken === undefined) {
|
|
69
|
+
throw new StepFailed(`no read status token in the state cache: nothing can ask https://${fqdn}/api/status ` +
|
|
70
|
+
"what it is running");
|
|
71
|
+
}
|
|
35
72
|
const required = requireOperatorConfig(context.config, ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN"], { env: context.env });
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
73
|
+
await deployCommit({
|
|
74
|
+
name: names.given,
|
|
75
|
+
appUuid,
|
|
76
|
+
fqdn,
|
|
77
|
+
sha,
|
|
78
|
+
readToken,
|
|
79
|
+
coolify: new CoolifyClient({
|
|
80
|
+
url: required.HF_COOLIFY_URL,
|
|
81
|
+
token: required.HF_COOLIFY_TOKEN,
|
|
82
|
+
fetch: context.fetch,
|
|
83
|
+
}),
|
|
84
|
+
io: context.io,
|
|
85
|
+
now: context.now,
|
|
86
|
+
sleep: context.sleep,
|
|
39
87
|
fetch: context.fetch,
|
|
40
88
|
});
|
|
41
|
-
const deadline = context.now() + DEPLOY_TIMEOUT_MS;
|
|
42
|
-
const { deployments } = await coolify.deploy(appUuid, { force: true });
|
|
43
|
-
const deploymentUuid = deployments[0]?.deployment_uuid;
|
|
44
|
-
if (deploymentUuid === undefined) {
|
|
45
|
-
throw new StepFailed(`Coolify accepted the deploy of ${names.given} but named no deployment`);
|
|
46
|
-
}
|
|
47
|
-
context.io.out(`${names.given}: deployment ${deploymentUuid} queued`);
|
|
48
|
-
await waitForBuild(context, coolify, deploymentUuid, deadline);
|
|
49
|
-
await waitForVersion(context, sha, deadline);
|
|
50
89
|
await context.state.patch({ lastDeployedSha: sha });
|
|
51
|
-
context.io.out(`${names.given}: serving ${short(sha)} at https://${appFqdn(context)}`);
|
|
52
90
|
},
|
|
53
91
|
};
|
|
54
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Points `SOURCE_COMMIT` at the commit about to be deployed, creating the entry if it is absent.
|
|
94
|
+
*
|
|
95
|
+
* Idempotent, and deliberately outside the `coolify` step's bulk PATCH: this is a value `hf` sets
|
|
96
|
+
* per deploy rather than one of the app's secrets, so it is neither in `secretsHash` nor in what
|
|
97
|
+
* `assertEnvsMatchCompose` compares. Every entry Coolify lists under the name is written, because
|
|
98
|
+
* a compose parse materialises a preview entry beside the non-preview one and the deploy reads
|
|
99
|
+
* its own.
|
|
100
|
+
*/
|
|
101
|
+
async function setSourceCommit(target) {
|
|
102
|
+
const existing = (await target.coolify.listEnvs(target.appUuid)).filter((entry) => entry.key === SOURCE_COMMIT_ENV);
|
|
103
|
+
const variable = {
|
|
104
|
+
key: SOURCE_COMMIT_ENV,
|
|
105
|
+
value: target.sha,
|
|
106
|
+
is_buildtime: true,
|
|
107
|
+
is_runtime: true,
|
|
108
|
+
};
|
|
109
|
+
if (existing.length === 0) {
|
|
110
|
+
await target.coolify.createEnv(target.appUuid, variable);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
for (const entry of existing) {
|
|
114
|
+
await target.coolify.updateEnv(target.appUuid, {
|
|
115
|
+
...variable,
|
|
116
|
+
is_preview: entry.is_preview ?? false,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
target.io.out(`${target.name}: ${SOURCE_COMMIT_ENV}=${short(target.sha)} set in Coolify`);
|
|
121
|
+
}
|
|
122
|
+
async function waitForBuild(target, deploymentUuid, deadline) {
|
|
55
123
|
for (let wait = FIRST_POLL_MS;; wait = Math.min(wait * 2, MAX_POLL_MS)) {
|
|
56
|
-
const deployment = await coolify.getDeployment(deploymentUuid);
|
|
124
|
+
const deployment = await target.coolify.getDeployment(deploymentUuid);
|
|
57
125
|
if (deployment.status === FINISHED)
|
|
58
126
|
return;
|
|
59
127
|
if (deployment.status.startsWith("failed") || deployment.status.startsWith("cancelled")) {
|
|
60
128
|
throw new StepFailed(`Coolify deployment ${deploymentUuid} ended ${deployment.status}: read the build log in ` +
|
|
61
129
|
"Coolify, fix it, and re-run hf new");
|
|
62
130
|
}
|
|
63
|
-
if (
|
|
131
|
+
if (target.now() >= deadline) {
|
|
64
132
|
throw new StepFailed(`Coolify deployment ${deploymentUuid} was still ${deployment.status} after ` +
|
|
65
133
|
`${String(DEPLOY_TIMEOUT_MS / 60_000)} minutes`);
|
|
66
134
|
}
|
|
67
|
-
await
|
|
135
|
+
await target.sleep(wait);
|
|
68
136
|
}
|
|
69
137
|
}
|
|
70
138
|
/**
|
|
71
|
-
* Polls `/api/status` under the read token until it reports `sha`.
|
|
139
|
+
* Polls `/api/status` under the read token until it reports `target.sha`.
|
|
72
140
|
*
|
|
73
141
|
* A refusal or an unparseable answer is not a failure here — the containers are restarting, and the
|
|
74
142
|
* old ones answer until the new ones are healthy — so only the deadline ends this loop.
|
|
75
143
|
*/
|
|
76
|
-
async function waitForVersion(
|
|
77
|
-
const url = `https://${
|
|
78
|
-
const
|
|
79
|
-
if (token === undefined) {
|
|
80
|
-
throw new StepFailed(`no read status token in the state cache: nothing can ask ${url} what it is running`);
|
|
81
|
-
}
|
|
82
|
-
const doFetch = context.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
144
|
+
async function waitForVersion(target, deadline) {
|
|
145
|
+
const url = `https://${target.fqdn}/api/status`;
|
|
146
|
+
const doFetch = target.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
83
147
|
let last = "nothing yet";
|
|
84
148
|
for (let wait = FIRST_POLL_MS;; wait = Math.min(wait * 2, MAX_POLL_MS)) {
|
|
85
149
|
try {
|
|
86
150
|
const response = await doFetch(url, {
|
|
87
|
-
headers: { authorization: `Bearer ${
|
|
151
|
+
headers: { authorization: `Bearer ${target.readToken}`, accept: "application/json" },
|
|
88
152
|
});
|
|
89
153
|
if (!response.ok) {
|
|
90
154
|
last = `HTTP ${String(response.status)}`;
|
|
@@ -92,7 +156,7 @@ async function waitForVersion(context, sha, deadline) {
|
|
|
92
156
|
else {
|
|
93
157
|
const report = (await response.json());
|
|
94
158
|
const version = report.applicationVersion;
|
|
95
|
-
if (version === sha)
|
|
159
|
+
if (version === target.sha)
|
|
96
160
|
return;
|
|
97
161
|
last = version === null ? "no applicationVersion" : `applicationVersion ${short(version)}`;
|
|
98
162
|
}
|
|
@@ -100,11 +164,11 @@ async function waitForVersion(context, sha, deadline) {
|
|
|
100
164
|
catch (error) {
|
|
101
165
|
last = error.message;
|
|
102
166
|
}
|
|
103
|
-
if (
|
|
104
|
-
throw new StepFailed(`${url} never reported ${short(sha)} within ` +
|
|
167
|
+
if (target.now() >= deadline) {
|
|
168
|
+
throw new StepFailed(`${url} never reported ${short(target.sha)} within ` +
|
|
105
169
|
`${String(DEPLOY_TIMEOUT_MS / 60_000)} minutes (last: ${last}). The build finished, so ` +
|
|
106
|
-
|
|
170
|
+
`check that ${SOURCE_COMMIT_ENV} reached the image — hf doctor reports the same mismatch.`);
|
|
107
171
|
}
|
|
108
|
-
await
|
|
172
|
+
await target.sleep(wait);
|
|
109
173
|
}
|
|
110
174
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* overrides it. One flat list of names, no nesting: these are pasted in from account pages, and
|
|
4
4
|
* a shape is one more thing to get wrong.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CONFIG_KEYS: readonly ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN", "HF_COOLIFY_SERVER_UUID", "HF_COOLIFY_GITHUB_APP_UUID", "HF_COOLIFY_POSTGRES_UUID", "HF_DB_HOST_INTERNAL", "HF_DB_CONTAINER", "HF_PG_ADMIN_USER", "HF_SSH_HOST", "HF_CLOUDFLARE_TOKEN", "HF_CLOUDFLARE_ZONE_ID", "HF_BASE_DOMAIN", "HF_GITHUB_TOKEN", "HF_GITHUB_OWNER", "HF_GITHUB_APP_SLUGS", "HF_SENTRY_TOKEN", "HF_SENTRY_ORG", "HF_LANGFUSE_URL", "HF_LANGFUSE_ORG_KEY", "HF_LANGFUSE_PUBLIC_KEY", "HF_LANGFUSE_SECRET_KEY", "HF_BOX_IP", "HF_SMTP_URL", "HF_EMAIL_FROM", "HF_ANTHROPIC_API_KEY", "HF_OPENAI_API_KEY"];
|
|
6
|
+
export declare const CONFIG_KEYS: readonly ["HF_COOLIFY_URL", "HF_COOLIFY_TOKEN", "HF_COOLIFY_SERVER_UUID", "HF_COOLIFY_GITHUB_APP_UUID", "HF_COOLIFY_POSTGRES_UUID", "HF_COOLIFY_S3_STORAGE_UUID", "HF_DB_HOST_INTERNAL", "HF_DB_CONTAINER", "HF_PG_ADMIN_USER", "HF_SSH_HOST", "HF_CLOUDFLARE_TOKEN", "HF_CLOUDFLARE_ZONE_ID", "HF_BASE_DOMAIN", "HF_GITHUB_TOKEN", "HF_GITHUB_OWNER", "HF_GITHUB_APP_SLUGS", "HF_SENTRY_TOKEN", "HF_SENTRY_ORG", "HF_LANGFUSE_URL", "HF_LANGFUSE_ORG_KEY", "HF_LANGFUSE_PUBLIC_KEY", "HF_LANGFUSE_SECRET_KEY", "HF_BOX_IP", "HF_SMTP_URL", "HF_EMAIL_FROM", "HF_ANTHROPIC_API_KEY", "HF_OPENAI_API_KEY"];
|
|
7
7
|
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
8
8
|
/** What the operator has configured. Every key is optional until a command asks for it. */
|
|
9
9
|
export type OperatorConfig = Partial<Record<ConfigKey, string>>;
|
package/dist/config.js
CHANGED
|
@@ -12,6 +12,11 @@ export const CONFIG_KEYS = [
|
|
|
12
12
|
"HF_COOLIFY_SERVER_UUID",
|
|
13
13
|
"HF_COOLIFY_GITHUB_APP_UUID",
|
|
14
14
|
"HF_COOLIFY_POSTGRES_UUID",
|
|
15
|
+
// Optional: the Coolify S3 storage the daily dump is uploaded to. Unset, the backup step asks
|
|
16
|
+
// `GET /s3-storages` and takes the one usable storage when there is exactly one; this is how a
|
|
17
|
+
// box with several — or with one the step should not pick — is told which, rather than guessed
|
|
18
|
+
// at. With neither the schedule is registered local-only and the step warns.
|
|
19
|
+
"HF_COOLIFY_S3_STORAGE_UUID",
|
|
15
20
|
// The Postgres container's hostname on the docker network, as the app's containers see it.
|
|
16
21
|
// Configurable because Coolify's API document reports no such field: its own compose generator
|
|
17
22
|
// names the container after the database's uuid, so `HF_COOLIFY_POSTGRES_UUID` is the default a
|
package/dist/database.d.ts
CHANGED
|
@@ -29,6 +29,14 @@ export interface Database {
|
|
|
29
29
|
host: string;
|
|
30
30
|
port: number;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* The Postgres container this cluster runs in, when opening it had to discover one.
|
|
34
|
+
*
|
|
35
|
+
* What `hf restore-check` runs `pg_restore` inside: the box host has no Postgres client tools,
|
|
36
|
+
* only the container does. `undefined` when the box's own loopback answered and no container
|
|
37
|
+
* was ever looked for.
|
|
38
|
+
*/
|
|
39
|
+
readonly container?: string;
|
|
32
40
|
/** A libpq URL onto `databaseName`, or `undefined` when the transport has no address. */
|
|
33
41
|
adminUrl(databaseName?: string): string | undefined;
|
|
34
42
|
query(sql: string, options?: QueryOptions): Promise<QueryResult>;
|
|
@@ -93,3 +101,15 @@ export declare const DEFAULT_POSTGRES_PORT = 5432;
|
|
|
93
101
|
export declare function openDatabase(runner: Runner, options: OpenDatabaseOptions): Promise<Database>;
|
|
94
102
|
/** The cluster at a URL this process can already dial — a test's Postgres, or a live tunnel. */
|
|
95
103
|
export declare function openDatabaseUrl(adminUrl: string): Database;
|
|
104
|
+
/**
|
|
105
|
+
* The first of `containers` that exists, and its own address, asked of the box.
|
|
106
|
+
*
|
|
107
|
+
* The `coolify` network by name, because a Coolify service also sits on a per-service network
|
|
108
|
+
* that only its own stack is on; the first address is the fallback for a box that names its
|
|
109
|
+
* network something else. Every candidate that failed is reported, because which name a database
|
|
110
|
+
* got is a fact about how it was created and the operator is the one who knows it.
|
|
111
|
+
*/
|
|
112
|
+
export declare function findPostgresContainer(runner: Runner, containers: readonly string[]): Promise<{
|
|
113
|
+
container: string;
|
|
114
|
+
address: string;
|
|
115
|
+
}>;
|