@hyperfixation/cli 0.1.1 → 0.1.3
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/checklist.js +1 -1
- package/dist/cloud-steps/context.d.ts +2 -1
- package/dist/cloud-steps/context.js +2 -1
- package/dist/cloud-steps/coolify.d.ts +23 -3
- package/dist/cloud-steps/coolify.js +34 -8
- package/dist/cloud-steps/langfuse.d.ts +5 -0
- package/dist/cloud-steps/langfuse.js +36 -0
- package/dist/cloud-steps/repo.js +38 -3
- package/dist/config.d.ts +13 -1
- package/dist/config.js +37 -0
- package/dist/database.d.ts +41 -11
- package/dist/database.js +100 -16
- package/dist/doctor.d.ts +4 -3
- package/dist/doctor.js +80 -22
- package/dist/new-cloud.d.ts +11 -2
- package/dist/new-cloud.js +30 -21
- package/dist/providers/cloudflare.js +1 -0
- package/dist/providers/coolify.d.ts +15 -1
- package/dist/providers/coolify.js +4 -0
- package/dist/providers/github.js +1 -0
- package/dist/providers/http.d.ts +23 -6
- package/dist/providers/http.js +46 -8
- package/dist/providers/langfuse.js +2 -0
- package/dist/providers/sentry.js +1 -0
- package/dist/provision-database.js +2 -2
- package/dist/restore-check.js +16 -11
- package/dist/runner.d.ts +10 -3
- package/dist/runner.js +32 -10
- package/package.json +6 -6
package/dist/doctor.d.ts
CHANGED
|
@@ -49,19 +49,20 @@ export interface DoctorOptions {
|
|
|
49
49
|
* whole point of this command is one screen that says whether anything needs attention.
|
|
50
50
|
*
|
|
51
51
|
* Nothing here prints a secret. The read token authorizes the status request and never appears
|
|
52
|
-
* in a finding
|
|
52
|
+
* in a finding, and a provider's refusal reaches a finding redacted (`ProviderError`).
|
|
53
53
|
*/
|
|
54
54
|
export declare function doctor(options?: DoctorOptions): Promise<DoctorResult>;
|
|
55
55
|
/** The report as printed: a blank line and a header per app, then its findings. */
|
|
56
56
|
export declare function doctorLines(result: DoctorResult): string[];
|
|
57
57
|
/**
|
|
58
|
-
* E006 as
|
|
58
|
+
* E006 as the cluster admin with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
|
|
59
59
|
*
|
|
60
60
|
* As the app role rather than as an admin because that is the only role whose answer matters —
|
|
61
61
|
* a superuser's privileges are both true whatever the migrator granted.
|
|
62
62
|
*/
|
|
63
63
|
export declare function tunnelPrivilegeCheck(runner: Runner, options?: {
|
|
64
|
-
|
|
64
|
+
containers?: readonly string[];
|
|
65
|
+
adminUser?: string;
|
|
65
66
|
}): PrivilegeCheck;
|
|
66
67
|
/**
|
|
67
68
|
* `@hyperfixation/db`'s own E006, run against `adminUrl` — which must already name the app's
|
package/dist/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { access, readdir } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { checkE006, quoteIdent } from "@hyperfixation/db";
|
|
4
4
|
import { Client } from "pg";
|
|
5
|
-
import { loadOperatorConfig, requireOperatorConfig, } from "./config.js";
|
|
5
|
+
import { DEFAULT_PG_ADMIN_USER, loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
|
|
6
6
|
import { openDatabase } from "./database.js";
|
|
7
7
|
import { deriveNames } from "./names.js";
|
|
8
8
|
import { GithubClient } from "./providers/github.js";
|
|
@@ -12,8 +12,6 @@ import { openAppState, stateDir } from "./state.js";
|
|
|
12
12
|
export const RESTORE_CHECK_MAX_AGE_DAYS = 7;
|
|
13
13
|
/** The branch prefix Phase 4's core bumps open their pull requests on. */
|
|
14
14
|
export const CORE_BUMP_BRANCH_PREFIX = "core-bump/";
|
|
15
|
-
/** The cluster role `hf doctor` reads privileges as, before `SET ROLE`. */
|
|
16
|
-
const CLUSTER_ADMIN_USER = "postgres";
|
|
17
15
|
/**
|
|
18
16
|
* `hf doctor` — what is wrong with the deployed apps, one line per finding.
|
|
19
17
|
*
|
|
@@ -22,7 +20,7 @@ const CLUSTER_ADMIN_USER = "postgres";
|
|
|
22
20
|
* whole point of this command is one screen that says whether anything needs attention.
|
|
23
21
|
*
|
|
24
22
|
* Nothing here prints a secret. The read token authorizes the status request and never appears
|
|
25
|
-
* in a finding
|
|
23
|
+
* in a finding, and a provider's refusal reaches a finding redacted (`ProviderError`).
|
|
26
24
|
*/
|
|
27
25
|
export async function doctor(options = {}) {
|
|
28
26
|
const env = options.env ?? process.env;
|
|
@@ -63,21 +61,22 @@ export function doctorLines(result) {
|
|
|
63
61
|
return lines;
|
|
64
62
|
}
|
|
65
63
|
/**
|
|
66
|
-
* E006 as
|
|
64
|
+
* E006 as the cluster admin with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
|
|
67
65
|
*
|
|
68
66
|
* As the app role rather than as an admin because that is the only role whose answer matters —
|
|
69
67
|
* a superuser's privileges are both true whatever the migrator granted.
|
|
70
68
|
*/
|
|
71
69
|
export function tunnelPrivilegeCheck(runner, options = {}) {
|
|
70
|
+
const adminUser = options.adminUser ?? DEFAULT_PG_ADMIN_USER;
|
|
72
71
|
return async (target) => {
|
|
73
72
|
const db = await openDatabase(runner, {
|
|
74
|
-
admin: { user:
|
|
75
|
-
|
|
73
|
+
admin: { user: adminUser },
|
|
74
|
+
containers: options.containers,
|
|
76
75
|
});
|
|
77
76
|
try {
|
|
78
77
|
const adminUrl = db.adminUrl(target.databaseName);
|
|
79
78
|
if (adminUrl === undefined) {
|
|
80
|
-
throw new Error(`E006 cannot be read over the ${db.kind} transport: ${
|
|
79
|
+
throw new Error(`E006 cannot be read over the ${db.kind} transport: ${adminUser} has to be ` +
|
|
81
80
|
"a session a pg client holds open, so that SET ROLE outlives the statement");
|
|
82
81
|
}
|
|
83
82
|
await checkAppRolePrivileges(adminUrl, target.applicationRole);
|
|
@@ -105,7 +104,10 @@ export async function checkAppRolePrivileges(adminUrl, role) {
|
|
|
105
104
|
}
|
|
106
105
|
function defaultPrivilegeCheck(config, env) {
|
|
107
106
|
const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
|
|
108
|
-
return tunnelPrivilegeCheck(createSshRunner({ host: HF_SSH_HOST })
|
|
107
|
+
return tunnelPrivilegeCheck(createSshRunner({ host: HF_SSH_HOST }), {
|
|
108
|
+
containers: postgresContainers(config),
|
|
109
|
+
adminUser: pgAdminUser(config),
|
|
110
|
+
});
|
|
109
111
|
}
|
|
110
112
|
async function doctorApp(context, name) {
|
|
111
113
|
const findings = [];
|
|
@@ -141,7 +143,7 @@ async function doctorApp(context, name) {
|
|
|
141
143
|
}
|
|
142
144
|
const report = await statusFindings(context, name, state, add);
|
|
143
145
|
versionFinding(report?.applicationVersion, mainSha, mainShaProblem, add);
|
|
144
|
-
if (report !== undefined) {
|
|
146
|
+
if (report?.budget !== undefined) {
|
|
145
147
|
budgetFinding(report.budget.current, "current", add);
|
|
146
148
|
budgetFinding(report.budget.previous, "previous", add);
|
|
147
149
|
}
|
|
@@ -161,23 +163,75 @@ async function statusFindings(context, name, state, add) {
|
|
|
161
163
|
}
|
|
162
164
|
let report;
|
|
163
165
|
try {
|
|
164
|
-
report = await getStatus(context.fetch, url, token);
|
|
166
|
+
report = readStatus(await getStatus(context.fetch, url, token));
|
|
165
167
|
}
|
|
166
168
|
catch (error) {
|
|
167
169
|
add("status", "fail", `GET ${url}: ${flatten(error.message)}`);
|
|
168
170
|
return undefined;
|
|
169
171
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (report.
|
|
172
|
+
const anomalies = report.anomalies === undefined ? UNKNOWN : String(report.anomalies);
|
|
173
|
+
add("status",
|
|
174
|
+
// Only a health the app actually reported can be a warning: a field it did not answer with
|
|
175
|
+
// says nothing about the deployment, and a WARN the operator cannot act on is noise.
|
|
176
|
+
report.health === undefined || report.health === "ok" ? "ok" : "warn", `health ${report.health ?? UNKNOWN}, ${anomalies} anomaly/anomalies, core ` +
|
|
177
|
+
(report.coreVersion ?? UNKNOWN));
|
|
178
|
+
if (report.runsRunning !== undefined) {
|
|
179
|
+
add("runs", "ok", `${String(report.runsRunning)} run(s) running`);
|
|
180
|
+
}
|
|
181
|
+
// Only `fixtures` gets a line. `live` is the expected deploy, and `unknown` — as is a core too
|
|
182
|
+
// old to have the field at all — is an app that has not said; neither is a finding, but a
|
|
183
|
+
// canned draft an operator takes for a real one is.
|
|
184
|
+
if (report.llmMode === "fixtures") {
|
|
177
185
|
add("llm", "warn", "app is serving fixture drafts — no provider key set");
|
|
178
186
|
}
|
|
179
187
|
return report;
|
|
180
188
|
}
|
|
189
|
+
const UNKNOWN = "unknown";
|
|
190
|
+
function readStatus(payload) {
|
|
191
|
+
const report = record(payload);
|
|
192
|
+
const budget = record(report.budget);
|
|
193
|
+
return {
|
|
194
|
+
health: text(report.health),
|
|
195
|
+
anomalies: numeric(report.anomalies),
|
|
196
|
+
coreVersion: text(report.coreVersion),
|
|
197
|
+
applicationVersion: report.applicationVersion === null ? null : text(report.applicationVersion),
|
|
198
|
+
runsRunning: numeric(record(report.runs).running),
|
|
199
|
+
llmMode: text(record(report.llm).mode),
|
|
200
|
+
budget: isRecord(report.budget)
|
|
201
|
+
? { current: readPeriod(budget.current), previous: readPeriod(budget.previous) }
|
|
202
|
+
: undefined,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** `null` for a period the app has no row for, which is also how a malformed one reads. */
|
|
206
|
+
function readPeriod(value) {
|
|
207
|
+
if (!isRecord(value))
|
|
208
|
+
return null;
|
|
209
|
+
return {
|
|
210
|
+
period: text(value.period),
|
|
211
|
+
budgetUsd: text(value.budgetUsd),
|
|
212
|
+
spentUsd: text(value.spentUsd),
|
|
213
|
+
driftUsd: text(value.driftUsd),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function isRecord(value) {
|
|
217
|
+
return typeof value === "object" && value !== null;
|
|
218
|
+
}
|
|
219
|
+
function record(value) {
|
|
220
|
+
return isRecord(value) ? value : {};
|
|
221
|
+
}
|
|
222
|
+
function text(value) {
|
|
223
|
+
return typeof value === "string" ? value : undefined;
|
|
224
|
+
}
|
|
225
|
+
function numeric(value) {
|
|
226
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
227
|
+
}
|
|
228
|
+
/** A money column as a number, or `undefined` when the app did not report a usable one. */
|
|
229
|
+
function amount(value) {
|
|
230
|
+
if (value === undefined)
|
|
231
|
+
return undefined;
|
|
232
|
+
const parsed = Number(value);
|
|
233
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
234
|
+
}
|
|
181
235
|
function versionFinding(deployed, mainSha, mainShaProblem, add) {
|
|
182
236
|
if (mainShaProblem !== undefined) {
|
|
183
237
|
add("version", "fail", mainShaProblem);
|
|
@@ -198,9 +252,13 @@ function budgetFinding(period, which, add) {
|
|
|
198
252
|
add("budget", "ok", `no ${which} period row yet`);
|
|
199
253
|
return;
|
|
200
254
|
}
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const
|
|
255
|
+
const spent = amount(period.spentUsd);
|
|
256
|
+
const budget = amount(period.budgetUsd);
|
|
257
|
+
const drift = amount(period.driftUsd);
|
|
258
|
+
const over = spent !== undefined && budget !== undefined && spent > budget;
|
|
259
|
+
const drifting = drift !== undefined && drift !== 0;
|
|
260
|
+
const spend = `${period.period ?? UNKNOWN} spent $${period.spentUsd ?? UNKNOWN} ` +
|
|
261
|
+
`of $${period.budgetUsd ?? UNKNOWN}`;
|
|
204
262
|
if (over || drifting) {
|
|
205
263
|
add("budget", "warn", `${spend}${over ? " — over budget" : ""}${drifting ? ` — drift $${period.driftUsd}` : ""}`);
|
|
206
264
|
return;
|
|
@@ -283,7 +341,7 @@ async function getStatus(fetchImpl, url, token) {
|
|
|
283
341
|
});
|
|
284
342
|
if (!response.ok)
|
|
285
343
|
throw new Error(`HTTP ${String(response.status)}`);
|
|
286
|
-
return
|
|
344
|
+
return await response.json();
|
|
287
345
|
}
|
|
288
346
|
async function stateNames(dir) {
|
|
289
347
|
const entries = await readdir(dir).catch(() => []);
|
package/dist/new-cloud.d.ts
CHANGED
|
@@ -75,13 +75,22 @@ export declare function invalidateStaleSecretSteps(state: AppStateStore): Promis
|
|
|
75
75
|
* Errors propagate untouched: the caller prints them, and the state file is the resume point.
|
|
76
76
|
*/
|
|
77
77
|
export declare function runSteps<Context extends CloudContext>(steps: readonly Step<Context>[], context: Context): Promise<RunStepsResult>;
|
|
78
|
+
/**
|
|
79
|
+
* The keys a cloud `hf new` runs without: three have a default derived from another key, the two
|
|
80
|
+
* provider keys are what the checklist warns about when they are unset, and the three Langfuse
|
|
81
|
+
* keys are three ways of configuring one step — an org key, a project key pair, or neither, which
|
|
82
|
+
* the step degrades to a warning and a checklist line.
|
|
83
|
+
*/
|
|
84
|
+
export declare const OPTIONAL_CLOUD_CONFIG: readonly ConfigKey[];
|
|
78
85
|
/**
|
|
79
86
|
* Every operator config key a cloud `hf new` needs, checked before the first step.
|
|
80
87
|
*
|
|
81
88
|
* All at once, and before anything is created: `requireOperatorConfig` names every missing key,
|
|
82
89
|
* and an operator who learns about them one failed step at a time pays for a half-provisioned app
|
|
83
|
-
* each time.
|
|
84
|
-
* the
|
|
90
|
+
* each time. Derived from `CONFIG_KEYS` rather than listed, because a hand-kept list is exactly
|
|
91
|
+
* what left `HF_GITHUB_TOKEN`, the Cloudflare pair and five others to fail at their own step: the
|
|
92
|
+
* ten steps between them read every key there is, so the required set is the complement of the
|
|
93
|
+
* optional one, and a key added for a step is required the moment it is named.
|
|
85
94
|
*/
|
|
86
95
|
export declare const REQUIRED_CLOUD_CONFIG: readonly ConfigKey[];
|
|
87
96
|
export interface NewAppCloudOptions {
|
package/dist/new-cloud.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { checklistLines } from "./checklist.js";
|
|
3
3
|
import { providerKeys } from "./cloud-steps/coolify.js";
|
|
4
4
|
import { cloudCommands, CLOUD_STEPS, defaultTemplateFetch, spawnStepExec, } from "./cloud-steps/index.js";
|
|
5
|
-
import { loadOperatorConfig, requireOperatorConfig, } from "./config.js";
|
|
5
|
+
import { CONFIG_KEYS, loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
|
|
6
6
|
import { openDatabase } from "./database.js";
|
|
7
7
|
import { deriveNames } from "./names.js";
|
|
8
8
|
import { createSshRunner } from "./runner.js";
|
|
@@ -106,28 +106,33 @@ export async function runSteps(steps, context) {
|
|
|
106
106
|
assertRotationApplied(context);
|
|
107
107
|
return { ran, skipped, invalidated };
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The keys a cloud `hf new` runs without: three have a default derived from another key, the two
|
|
111
|
+
* provider keys are what the checklist warns about when they are unset, and the three Langfuse
|
|
112
|
+
* keys are three ways of configuring one step — an org key, a project key pair, or neither, which
|
|
113
|
+
* the step degrades to a warning and a checklist line.
|
|
114
|
+
*/
|
|
115
|
+
export const OPTIONAL_CLOUD_CONFIG = [
|
|
116
|
+
"HF_DB_HOST_INTERNAL",
|
|
117
|
+
"HF_DB_CONTAINER",
|
|
118
|
+
"HF_PG_ADMIN_USER",
|
|
119
|
+
"HF_ANTHROPIC_API_KEY",
|
|
120
|
+
"HF_OPENAI_API_KEY",
|
|
121
|
+
"HF_LANGFUSE_ORG_KEY",
|
|
122
|
+
"HF_LANGFUSE_PUBLIC_KEY",
|
|
123
|
+
"HF_LANGFUSE_SECRET_KEY",
|
|
124
|
+
];
|
|
109
125
|
/**
|
|
110
126
|
* Every operator config key a cloud `hf new` needs, checked before the first step.
|
|
111
127
|
*
|
|
112
128
|
* All at once, and before anything is created: `requireOperatorConfig` names every missing key,
|
|
113
129
|
* and an operator who learns about them one failed step at a time pays for a half-provisioned app
|
|
114
|
-
* each time.
|
|
115
|
-
* the
|
|
130
|
+
* each time. Derived from `CONFIG_KEYS` rather than listed, because a hand-kept list is exactly
|
|
131
|
+
* what left `HF_GITHUB_TOKEN`, the Cloudflare pair and five others to fail at their own step: the
|
|
132
|
+
* ten steps between them read every key there is, so the required set is the complement of the
|
|
133
|
+
* optional one, and a key added for a step is required the moment it is named.
|
|
116
134
|
*/
|
|
117
|
-
export const REQUIRED_CLOUD_CONFIG =
|
|
118
|
-
"HF_COOLIFY_URL",
|
|
119
|
-
"HF_COOLIFY_TOKEN",
|
|
120
|
-
"HF_COOLIFY_SERVER_UUID",
|
|
121
|
-
"HF_COOLIFY_GITHUB_APP_UUID",
|
|
122
|
-
"HF_COOLIFY_POSTGRES_UUID",
|
|
123
|
-
"HF_SSH_HOST",
|
|
124
|
-
"HF_BASE_DOMAIN",
|
|
125
|
-
"HF_SMTP_URL",
|
|
126
|
-
"HF_EMAIL_FROM",
|
|
127
|
-
"HF_LANGFUSE_URL",
|
|
128
|
-
];
|
|
129
|
-
/** The cluster role `hf new` provisions the app's database and roles as. */
|
|
130
|
-
const CLUSTER_ADMIN_USER = "postgres";
|
|
135
|
+
export const REQUIRED_CLOUD_CONFIG = CONFIG_KEYS.filter((key) => !OPTIONAL_CLOUD_CONFIG.includes(key));
|
|
131
136
|
/**
|
|
132
137
|
* `hf new <name>` without `--local`: the ten steps, resumable, then the checklist.
|
|
133
138
|
*
|
|
@@ -144,7 +149,7 @@ export async function newAppCloud(options) {
|
|
|
144
149
|
const runner = options.runner ?? createSshRunner({ host: required.HF_SSH_HOST });
|
|
145
150
|
// `PGPASSWORD` is libpq's own name for it, and the same place `hf restore-check` reads it:
|
|
146
151
|
// Coolify's cluster password is not an hf config key, because nothing of ours should hold it.
|
|
147
|
-
const clusterAdmin = options.clusterAdmin ?? { user:
|
|
152
|
+
const clusterAdmin = options.clusterAdmin ?? { user: pgAdminUser(config), password: env.PGPASSWORD };
|
|
148
153
|
let database;
|
|
149
154
|
const hadWriteToken = state.state.statusTokens?.write !== undefined;
|
|
150
155
|
const fqdn = `${names.given}.${required.HF_BASE_DOMAIN}`;
|
|
@@ -164,9 +169,13 @@ export async function newAppCloud(options) {
|
|
|
164
169
|
email: options.email,
|
|
165
170
|
budgetUsd: options.budgetUsd,
|
|
166
171
|
database: async () => {
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
172
|
+
// The container is named so the tunnel can discover its address, but no `dockerExec`: that
|
|
173
|
+
// transport has no address, and every use of the cluster here — `provisionRoles`, the
|
|
174
|
+
// migrator, the tokens — is a pg client.
|
|
175
|
+
database ??= await openDatabase(runner, {
|
|
176
|
+
admin: clusterAdmin,
|
|
177
|
+
containers: postgresContainers(config),
|
|
178
|
+
});
|
|
170
179
|
return database;
|
|
171
180
|
},
|
|
172
181
|
commands: options.commands ?? cloudCommands,
|
|
@@ -29,14 +29,28 @@ export interface CoolifyApplicationRequest {
|
|
|
29
29
|
build_pack: "nixpacks" | "railpack" | "static" | "dockerfile" | "dockercompose";
|
|
30
30
|
name?: string;
|
|
31
31
|
description?: string;
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Comma-separated. Rejected outright for a `dockercompose` build pack — Coolify 4.3.21 answers
|
|
34
|
+
* 422 `The domains field cannot be used for dockercompose applications` — so
|
|
35
|
+
* `docker_compose_domains` is what `hf new` sends and this is for the other four packs.
|
|
36
|
+
*/
|
|
33
37
|
domains?: string;
|
|
38
|
+
/** One entry per compose service that gets a domain; `hf new` sends exactly one. */
|
|
39
|
+
docker_compose_domains?: readonly CoolifyComposeDomain[];
|
|
34
40
|
ports_exposes?: string;
|
|
35
41
|
docker_compose_location?: string;
|
|
36
42
|
connect_to_docker_network?: boolean;
|
|
37
43
|
instant_deploy?: boolean;
|
|
38
44
|
is_auto_deploy_enabled?: boolean;
|
|
39
45
|
}
|
|
46
|
+
/** A domain on one service of a compose application, which is how a `dockercompose` app gets one. */
|
|
47
|
+
export interface CoolifyComposeDomain {
|
|
48
|
+
/** The service name as `docker-compose.prod.yml` spells it. */
|
|
49
|
+
name: string;
|
|
50
|
+
/** Comma-separated, same as `domains`. */
|
|
51
|
+
domain: string;
|
|
52
|
+
redirect?: "www" | "non-www" | "both";
|
|
53
|
+
}
|
|
40
54
|
export interface CoolifyApplication {
|
|
41
55
|
uuid: string;
|
|
42
56
|
}
|
|
@@ -6,6 +6,7 @@ export class CoolifyClient {
|
|
|
6
6
|
provider: "coolify",
|
|
7
7
|
baseUrl: `${options.url.replace(/\/+$/, "")}/api/v1`,
|
|
8
8
|
headers: { authorization: `Bearer ${options.token}` },
|
|
9
|
+
secrets: [options.token],
|
|
9
10
|
fetch: options.fetch,
|
|
10
11
|
});
|
|
11
12
|
}
|
|
@@ -42,6 +43,9 @@ export class CoolifyClient {
|
|
|
42
43
|
method: "PATCH",
|
|
43
44
|
path: `/applications/${segment(appUuid)}/envs/bulk`,
|
|
44
45
|
body: { data },
|
|
46
|
+
// The app's whole environment goes out in this one call, and a 422 names the variables it
|
|
47
|
+
// rejected by quoting them.
|
|
48
|
+
secrets: data.map((variable) => variable.value),
|
|
45
49
|
});
|
|
46
50
|
}
|
|
47
51
|
async deploy(uuid, options = {}) {
|
package/dist/providers/github.js
CHANGED
package/dist/providers/http.d.ts
CHANGED
|
@@ -7,15 +7,23 @@ export interface ProviderRequest {
|
|
|
7
7
|
path: string;
|
|
8
8
|
query?: Record<string, string | number | boolean | undefined>;
|
|
9
9
|
body?: unknown;
|
|
10
|
+
/**
|
|
11
|
+
* Values this request carries that a provider may echo back at it — the app's environment, in
|
|
12
|
+
* the one call that sends it. Blanked out of the error message; see `explain`.
|
|
13
|
+
*/
|
|
14
|
+
secrets?: readonly string[];
|
|
10
15
|
}
|
|
16
|
+
/** How much of a rejected request's explanation reaches the message before it is cut. */
|
|
17
|
+
export declare const EXPLANATION_LIMIT = 500;
|
|
11
18
|
/**
|
|
12
19
|
* A provider answered with a status outside 2xx.
|
|
13
20
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* for
|
|
21
|
+
* `message` carries the response's own `message` and `errors` and nothing else of it: those two are
|
|
22
|
+
* where every provider here puts the reason, and the rest of a body is free to quote what was sent.
|
|
23
|
+
* What reaches the message is redacted first — every credential the client was built with, every
|
|
24
|
+
* value the request declared, and anything shaped like a password — because `hf` prints
|
|
25
|
+
* `error.message` and a terminal keeps a scrollback. The raw body is on `body` for a caller that
|
|
26
|
+
* wants it; the request's own headers and query string are in neither.
|
|
19
27
|
*/
|
|
20
28
|
export declare class ProviderError extends Error {
|
|
21
29
|
readonly provider: string;
|
|
@@ -23,7 +31,7 @@ export declare class ProviderError extends Error {
|
|
|
23
31
|
readonly method: HttpMethod;
|
|
24
32
|
readonly path: string;
|
|
25
33
|
readonly body: string;
|
|
26
|
-
constructor(provider: string, request: ProviderRequest, status: number, body: string);
|
|
34
|
+
constructor(provider: string, request: ProviderRequest, status: number, body: string, explanation?: string);
|
|
27
35
|
}
|
|
28
36
|
export interface TransportOptions {
|
|
29
37
|
/** Names the provider in errors. */
|
|
@@ -32,8 +40,17 @@ export interface TransportOptions {
|
|
|
32
40
|
baseUrl: string;
|
|
33
41
|
/** Sent on every request — the authorization header, and whatever else the API insists on. */
|
|
34
42
|
headers: Record<string, string>;
|
|
43
|
+
/** The credentials this client was built with; blanked out of every error message. */
|
|
44
|
+
secrets?: readonly string[];
|
|
35
45
|
fetch?: FetchLike;
|
|
36
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Why the provider refused, out of its `message` and `errors` and redacted.
|
|
49
|
+
*
|
|
50
|
+
* `undefined` when the body is not JSON or carries neither key: a provider that explained nothing
|
|
51
|
+
* leaves the status to speak, rather than a page of HTML in a terminal.
|
|
52
|
+
*/
|
|
53
|
+
export declare function explain(body: string, secrets: readonly string[]): string | undefined;
|
|
37
54
|
export type Transport = <Result>(request: ProviderRequest) => Promise<Result>;
|
|
38
55
|
/** Builds the `request` function the clients in this directory are written against. */
|
|
39
56
|
export declare function createTransport(options: TransportOptions): Transport;
|
package/dist/providers/http.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { redactPasswords } from "../database.js";
|
|
2
|
+
/** How much of a rejected request's explanation reaches the message before it is cut. */
|
|
3
|
+
export const EXPLANATION_LIMIT = 500;
|
|
1
4
|
/**
|
|
2
5
|
* A provider answered with a status outside 2xx.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* for
|
|
7
|
+
* `message` carries the response's own `message` and `errors` and nothing else of it: those two are
|
|
8
|
+
* where every provider here puts the reason, and the rest of a body is free to quote what was sent.
|
|
9
|
+
* What reaches the message is redacted first — every credential the client was built with, every
|
|
10
|
+
* value the request declared, and anything shaped like a password — because `hf` prints
|
|
11
|
+
* `error.message` and a terminal keeps a scrollback. The raw body is on `body` for a caller that
|
|
12
|
+
* wants it; the request's own headers and query string are in neither.
|
|
9
13
|
*/
|
|
10
14
|
export class ProviderError extends Error {
|
|
11
15
|
provider;
|
|
@@ -13,8 +17,9 @@ export class ProviderError extends Error {
|
|
|
13
17
|
method;
|
|
14
18
|
path;
|
|
15
19
|
body;
|
|
16
|
-
constructor(provider, request, status, body) {
|
|
17
|
-
super(`${provider} ${request.method} ${request.path} failed: HTTP ${status}`
|
|
20
|
+
constructor(provider, request, status, body, explanation) {
|
|
21
|
+
super(`${provider} ${request.method} ${request.path} failed: HTTP ${status}` +
|
|
22
|
+
(explanation === undefined ? "" : `: ${explanation}`));
|
|
18
23
|
this.name = "ProviderError";
|
|
19
24
|
this.provider = provider;
|
|
20
25
|
this.status = status;
|
|
@@ -23,6 +28,38 @@ export class ProviderError extends Error {
|
|
|
23
28
|
this.body = body;
|
|
24
29
|
}
|
|
25
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Why the provider refused, out of its `message` and `errors` and redacted.
|
|
33
|
+
*
|
|
34
|
+
* `undefined` when the body is not JSON or carries neither key: a provider that explained nothing
|
|
35
|
+
* leaves the status to speak, rather than a page of HTML in a terminal.
|
|
36
|
+
*/
|
|
37
|
+
export function explain(body, secrets) {
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(body);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (parsed === null || typeof parsed !== "object")
|
|
46
|
+
return undefined;
|
|
47
|
+
const { message, errors } = parsed;
|
|
48
|
+
if (message === undefined && errors === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
let text = JSON.stringify({
|
|
51
|
+
...(message === undefined ? {} : { message }),
|
|
52
|
+
...(errors === undefined ? {} : { errors }),
|
|
53
|
+
});
|
|
54
|
+
for (const secret of secrets) {
|
|
55
|
+
// Short values are skipped: a two-character secret would blank half the explanation with it.
|
|
56
|
+
if (secret.length < 4)
|
|
57
|
+
continue;
|
|
58
|
+
text = text.split(JSON.stringify(secret).slice(1, -1)).join("***");
|
|
59
|
+
}
|
|
60
|
+
text = redactPasswords(text);
|
|
61
|
+
return text.length > EXPLANATION_LIMIT ? `${text.slice(0, EXPLANATION_LIMIT)}…` : text;
|
|
62
|
+
}
|
|
26
63
|
/** Builds the `request` function the clients in this directory are written against. */
|
|
27
64
|
export function createTransport(options) {
|
|
28
65
|
// Looked up per request, not captured: a test's mock server replaces `globalThis.fetch` after
|
|
@@ -45,7 +82,8 @@ export function createTransport(options) {
|
|
|
45
82
|
});
|
|
46
83
|
const text = await response.text();
|
|
47
84
|
if (!response.ok) {
|
|
48
|
-
|
|
85
|
+
const secrets = [...(options.secrets ?? []), ...(request.secrets ?? [])];
|
|
86
|
+
throw new ProviderError(options.provider, request, response.status, text, explain(text, secrets));
|
|
49
87
|
}
|
|
50
88
|
return (text === "" ? undefined : JSON.parse(text));
|
|
51
89
|
};
|
|
@@ -8,6 +8,8 @@ export class LangfuseClient {
|
|
|
8
8
|
headers: {
|
|
9
9
|
authorization: `Basic ${Buffer.from(options.orgKey, "utf8").toString("base64")}`,
|
|
10
10
|
},
|
|
11
|
+
// Both halves of the pair: an error message must not quote either one back.
|
|
12
|
+
secrets: options.orgKey.split(":"),
|
|
11
13
|
fetch: options.fetch,
|
|
12
14
|
});
|
|
13
15
|
}
|
package/dist/providers/sentry.js
CHANGED
|
@@ -44,8 +44,8 @@ export async function provisionDatabase(target, options) {
|
|
|
44
44
|
const adminUrl = db.adminUrl();
|
|
45
45
|
if (adminUrl === undefined) {
|
|
46
46
|
throw new ProvisionDatabaseError(`roles cannot be provisioned over the ${db.kind} transport: provisionRoles() is a pg ` +
|
|
47
|
-
"client and needs an address.
|
|
48
|
-
"so the tunnel
|
|
47
|
+
"client and needs an address. Name the Postgres container — HF_DB_CONTAINER, or " +
|
|
48
|
+
"HF_COOLIFY_POSTGRES_UUID — so the tunnel can discover one.");
|
|
49
49
|
}
|
|
50
50
|
try {
|
|
51
51
|
const createdDatabase = await createDatabaseIfAbsent(db, names.databaseName);
|
package/dist/restore-check.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { quoteIdent } from "@hyperfixation/db";
|
|
2
2
|
import { createLocalDirectoryBackupSource, createS3BackupSource, } from "./backup-source.js";
|
|
3
|
-
import { loadOperatorConfig, requireOperatorConfig } from "./config.js";
|
|
3
|
+
import { loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
|
|
4
4
|
import { openDatabase, openDatabaseUrl, redactPasswords, DEFAULT_POSTGRES_PORT, } from "./database.js";
|
|
5
5
|
import { deriveNames } from "./names.js";
|
|
6
6
|
import { REQUIRED_EXTENSIONS } from "./provision-database.js";
|
|
@@ -12,8 +12,6 @@ export const SCRATCH_SUFFIX = "_restore_check";
|
|
|
12
12
|
const MAX_IDENTIFIER_BYTES = 63;
|
|
13
13
|
/** Older than this and the dump gets a warning line; it never changes the exit code. */
|
|
14
14
|
export const STALE_DUMP_HOURS = 36;
|
|
15
|
-
/** The box's Coolify Postgres superuser — the role the whole check runs as. */
|
|
16
|
-
const CLUSTER_ADMIN_USER = "postgres";
|
|
17
15
|
const CLUSTER_ADMIN_DATABASE = "postgres";
|
|
18
16
|
export class RestoreCheckError extends Error {
|
|
19
17
|
constructor(message) {
|
|
@@ -48,7 +46,8 @@ export async function restoreCheck(options) {
|
|
|
48
46
|
const clusterUrl = db.adminUrl();
|
|
49
47
|
if (clusterUrl === undefined) {
|
|
50
48
|
throw new RestoreCheckError(`a restore cannot be run over the ${db.kind} transport: pg_restore needs an address. ` +
|
|
51
|
-
"
|
|
49
|
+
"Name the Postgres container — HF_DB_CONTAINER, or HF_COOLIFY_POSTGRES_UUID — so the " +
|
|
50
|
+
"tunnel can discover one.");
|
|
52
51
|
}
|
|
53
52
|
const restoreTarget = urlOnto(options.restoreAdminUrl ?? clusterUrl, scratchDatabase);
|
|
54
53
|
const now = options.now ?? new Date();
|
|
@@ -219,8 +218,8 @@ export async function restoreCheckApp(options) {
|
|
|
219
218
|
const config = await loadOperatorConfig({ env });
|
|
220
219
|
const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
|
|
221
220
|
const runner = createSshRunner({ host: HF_SSH_HOST });
|
|
222
|
-
const admin = { user:
|
|
223
|
-
const db = await openDatabase(runner, { admin });
|
|
221
|
+
const admin = { user: pgAdminUser(config), password: env.PGPASSWORD };
|
|
222
|
+
const db = await openDatabase(runner, { admin, containers: postgresContainers(config) });
|
|
224
223
|
try {
|
|
225
224
|
return await restoreCheck({
|
|
226
225
|
app: options.app,
|
|
@@ -230,17 +229,23 @@ export async function restoreCheckApp(options) {
|
|
|
230
229
|
: createLocalDirectoryBackupSource({ runner, directory: options.backupDir }),
|
|
231
230
|
runner,
|
|
232
231
|
database: db,
|
|
233
|
-
restoreAdminUrl: boxAdminUrl(admin),
|
|
232
|
+
restoreAdminUrl: boxAdminUrl(admin, db.boxAddress),
|
|
234
233
|
});
|
|
235
234
|
}
|
|
236
235
|
finally {
|
|
237
236
|
await db.close();
|
|
238
237
|
}
|
|
239
238
|
}
|
|
240
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
239
|
+
/**
|
|
240
|
+
* The cluster as the box itself sees it, where `pg_restore` runs.
|
|
241
|
+
*
|
|
242
|
+
* `address` is whatever the tunnel settled on: with 5432 unpublished the box's loopback is no more
|
|
243
|
+
* a listener for `pg_restore` than for the forward, and the container's address on the docker
|
|
244
|
+
* network is what both have to dial.
|
|
245
|
+
*/
|
|
246
|
+
function boxAdminUrl(admin, address) {
|
|
247
|
+
const url = new URL(`postgresql://${address?.host ?? "127.0.0.1"}`);
|
|
248
|
+
url.port = String(address?.port ?? DEFAULT_POSTGRES_PORT);
|
|
244
249
|
url.username = encodeURIComponent(admin.user);
|
|
245
250
|
if (admin.password !== undefined)
|
|
246
251
|
url.password = encodeURIComponent(admin.password);
|