@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/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; a provider's response body is dropped for the same reason (`ProviderError`).
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 `postgres` with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
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
- container?: string;
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; a provider's response body is dropped for the same reason (`ProviderError`).
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 `postgres` with `SET ROLE hf_<app>`, over the tunnel a `Runner` opens.
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: CLUSTER_ADMIN_USER },
75
- container: options.container,
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: ${CLUSTER_ADMIN_USER} has to be ` +
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
- add("status", report.health === "ok" ? "ok" : "warn", `health ${report.health}, ${String(report.anomalies)} anomaly/anomalies, core ` +
171
- report.coreVersion);
172
- add("runs", "ok", `${String(report.runs.running)} run(s) running`);
173
- // Only `fixtures` gets a line. `live` is the expected deploy, and `unknown` is an app whose
174
- // worker has not reported yet neither is a finding, but a canned draft an operator takes
175
- // for a real one is.
176
- if (report.llm.mode === "fixtures") {
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 over = Number(period.spentUsd) > Number(period.budgetUsd);
202
- const drifting = Number(period.driftUsd) !== 0;
203
- const spend = `${period.period} spent $${period.spentUsd} of $${period.budgetUsd}`;
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 (await response.json());
344
+ return await response.json();
287
345
  }
288
346
  async function stateNames(dir) {
289
347
  const entries = await readdir(dir).catch(() => []);
@@ -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. The optional keys are deliberately absent `HF_DB_HOST_INTERNAL` has a default, and
84
- * the two provider keys are what the checklist warns about when they are unset.
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. The optional keys are deliberately absent `HF_DB_HOST_INTERNAL` has a default, and
115
- * the two provider keys are what the checklist warns about when they are unset.
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: CLUSTER_ADMIN_USER, password: env.PGPASSWORD };
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
- // No `container`: the `docker exec psql` transport has no address, and every use of the
168
- // cluster here `provisionRoles`, the migrator, the tokensis a pg client.
169
- database ??= await openDatabase(runner, { admin: clusterAdmin });
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,
@@ -7,6 +7,7 @@ export class CloudflareClient {
7
7
  provider: "cloudflare",
8
8
  baseUrl: options.url ?? CLOUDFLARE_API_URL,
9
9
  headers: { authorization: `Bearer ${options.token}` },
10
+ secrets: [options.token],
10
11
  fetch: options.fetch,
11
12
  });
12
13
  }
@@ -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
- /** Comma-separated; the app's `https://<name>.<HF_BASE_DOMAIN>`. */
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 = {}) {
@@ -11,6 +11,7 @@ export class GithubClient {
11
11
  accept: "application/vnd.github+json",
12
12
  "x-github-api-version": "2022-11-28",
13
13
  },
14
+ secrets: [options.token],
14
15
  fetch: options.fetch,
15
16
  });
16
17
  }
@@ -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
- * The response body is on `body` and deliberately **not** in `message`: a Coolify 422 echoes
15
- * the fields it rejected, and those fields are the app's whole environment its database URL,
16
- * its status tokens, its Langfuse secret key. `hf` prints `error.message`, so a body that
17
- * quotes a secret would land in a terminal and a scrollback. Nor is the query string included,
18
- * for the same reason.
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;
@@ -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
- * The response body is on `body` and deliberately **not** in `message`: a Coolify 422 echoes
5
- * the fields it rejected, and those fields are the app's whole environment its database URL,
6
- * its status tokens, its Langfuse secret key. `hf` prints `error.message`, so a body that
7
- * quotes a secret would land in a terminal and a scrollback. Nor is the query string included,
8
- * for the same reason.
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
- throw new ProviderError(options.provider, request, response.status, text);
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
  }
@@ -7,6 +7,7 @@ export class SentryClient {
7
7
  provider: "sentry",
8
8
  baseUrl: options.url ?? SENTRY_URL,
9
9
  headers: { authorization: `Bearer ${options.token}` },
10
+ secrets: [options.token],
10
11
  fetch: options.fetch,
11
12
  });
12
13
  }
@@ -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. Publish the Coolify Postgres port on the box's loopback " +
48
- "so the tunnel works.");
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);
@@ -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
- "Publish the Coolify Postgres port on the box's loopback so the tunnel works.");
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: CLUSTER_ADMIN_USER, password: env.PGPASSWORD };
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
- /** The cluster as the box itself sees it, where `pg_restore` runs. */
241
- function boxAdminUrl(admin) {
242
- const url = new URL("postgresql://127.0.0.1");
243
- url.port = String(DEFAULT_POSTGRES_PORT);
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);