@hyperfixation/cli 0.1.1 → 0.1.2
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/coolify.d.ts +13 -3
- package/dist/cloud-steps/coolify.js +23 -7
- 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 +3 -2
- package/dist/doctor.js +79 -21
- package/dist/new-cloud.d.ts +11 -2
- package/dist/new-cloud.js +30 -21
- 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/checklist.js
CHANGED
|
@@ -13,7 +13,7 @@ export function checklistLines(input) {
|
|
|
13
13
|
lines.push(` - ${parts[0]}`, ...parts.slice(1).map((part) => ` ${part}`), "");
|
|
14
14
|
};
|
|
15
15
|
if (input.providerKeysSent.length === 0) {
|
|
16
|
-
add("The app is serving FIXTURE drafts: no ANTHROPIC_API_KEY or OPENAI_API_KEY was configured,", "so llm.run returns canned text
|
|
16
|
+
add("The app is serving FIXTURE drafts: no ANTHROPIC_API_KEY or OPENAI_API_KEY was configured,", "so llm.run returns canned text. /api/status reports llm.mode=fixtures once the app's", "worker has reported the mode — until then, and on a core older than 0.1.1, it says", "unknown. Paste a key into Coolify's environment for this application and redeploy.");
|
|
17
17
|
}
|
|
18
18
|
add("Third-party keys go into Coolify's environment for this application — and every new name", "also has to be added to REQUIRED_ENV (src/env.ts), .env.example and all three", "environment: blocks of docker-compose.prod.yml. A name Coolify carries that compose does", "not interpolate never reaches a container, and hf new refuses the next run until they agree.");
|
|
19
19
|
add(`Metabase reads through ${roleNames(names.appName).readonly}:`, `postgres://${roleNames(names.appName).readonly}:<password>@${input.dbHost}:5432/${names.databaseName}`, `the password is database.readonlyPassword in ${input.stateFile}.`);
|
|
@@ -24,6 +24,14 @@ export declare const CONTAINER_PROVIDED_ENV: readonly ["HF_PROCESS", "HF_BUILD_S
|
|
|
24
24
|
* checklist is what says it out loud.
|
|
25
25
|
*/
|
|
26
26
|
export declare const OPTIONAL_PROVIDER_ENV: readonly ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
|
|
27
|
+
/**
|
|
28
|
+
* The three `hf new` omits when the `langfuse` step provisioned no keys.
|
|
29
|
+
*
|
|
30
|
+
* All three or none: the template's gate — `instrumentation.ts` in the web, `startWorker()` in the
|
|
31
|
+
* worker — registers the span processor only when none of them is empty, so a base URL on its own
|
|
32
|
+
* configures nothing and only reads as though it did.
|
|
33
|
+
*/
|
|
34
|
+
export declare const OPTIONAL_LANGFUSE_ENV: readonly ["LANGFUSE_BASE_URL", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"];
|
|
27
35
|
/**
|
|
28
36
|
* The app's environment and what `docker-compose.prod.yml` interpolates have diverged.
|
|
29
37
|
*
|
|
@@ -49,12 +57,14 @@ export declare function neededEnvNames(dir: string): Promise<string[]>;
|
|
|
49
57
|
/**
|
|
50
58
|
* Refuses to PATCH an environment that is not the one the compose file needs.
|
|
51
59
|
*
|
|
52
|
-
* A provider key the operator has not configured
|
|
53
|
-
*
|
|
60
|
+
* A provider key the operator has not configured, and the three Langfuse variables when the step
|
|
61
|
+
* provisioned no keys, are deliberately absent rather than missing: each is excused here and
|
|
62
|
+
* reported in the checklist instead.
|
|
54
63
|
*/
|
|
55
64
|
export declare function assertEnvsMatchCompose(dir: string, sent: readonly string[]): Promise<void>;
|
|
56
65
|
/**
|
|
57
|
-
* The twelve variables the deployed app runs on,
|
|
66
|
+
* The twelve variables the deployed app runs on, less any of the five optional ones — the two
|
|
67
|
+
* provider keys and the three Langfuse variables — that nothing provisioned.
|
|
58
68
|
*
|
|
59
69
|
* Generates `BETTER_AUTH_SECRET` on first sight and records it: it is the one value in the list
|
|
60
70
|
* that no provider hands back, so a run that did not persist it would lock every existing session
|
|
@@ -27,6 +27,18 @@ export const CONTAINER_PROVIDED_ENV = ["HF_PROCESS", "HF_BUILD_SHA"];
|
|
|
27
27
|
* checklist is what says it out loud.
|
|
28
28
|
*/
|
|
29
29
|
export const OPTIONAL_PROVIDER_ENV = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
|
|
30
|
+
/**
|
|
31
|
+
* The three `hf new` omits when the `langfuse` step provisioned no keys.
|
|
32
|
+
*
|
|
33
|
+
* All three or none: the template's gate — `instrumentation.ts` in the web, `startWorker()` in the
|
|
34
|
+
* worker — registers the span processor only when none of them is empty, so a base URL on its own
|
|
35
|
+
* configures nothing and only reads as though it did.
|
|
36
|
+
*/
|
|
37
|
+
export const OPTIONAL_LANGFUSE_ENV = [
|
|
38
|
+
"LANGFUSE_BASE_URL",
|
|
39
|
+
"LANGFUSE_PUBLIC_KEY",
|
|
40
|
+
"LANGFUSE_SECRET_KEY",
|
|
41
|
+
];
|
|
30
42
|
/** Which operator key carries each of them, in `REQUIRED_ENV` order. */
|
|
31
43
|
const PROVIDER_ENV_SOURCE = [
|
|
32
44
|
["ANTHROPIC_API_KEY", "HF_ANTHROPIC_API_KEY"],
|
|
@@ -88,20 +100,22 @@ export async function neededEnvNames(dir) {
|
|
|
88
100
|
/**
|
|
89
101
|
* Refuses to PATCH an environment that is not the one the compose file needs.
|
|
90
102
|
*
|
|
91
|
-
* A provider key the operator has not configured
|
|
92
|
-
*
|
|
103
|
+
* A provider key the operator has not configured, and the three Langfuse variables when the step
|
|
104
|
+
* provisioned no keys, are deliberately absent rather than missing: each is excused here and
|
|
105
|
+
* reported in the checklist instead.
|
|
93
106
|
*/
|
|
94
107
|
export async function assertEnvsMatchCompose(dir, sent) {
|
|
95
108
|
const needed = await neededEnvNames(dir);
|
|
96
109
|
const sentNames = new Set(sent);
|
|
97
|
-
const optional = new Set(OPTIONAL_PROVIDER_ENV);
|
|
110
|
+
const optional = new Set([...OPTIONAL_PROVIDER_ENV, ...OPTIONAL_LANGFUSE_ENV]);
|
|
98
111
|
const missing = needed.filter((name) => !sentNames.has(name) && !optional.has(name));
|
|
99
112
|
const extra = sent.filter((name) => !needed.includes(name));
|
|
100
113
|
if (missing.length > 0 || extra.length > 0)
|
|
101
114
|
throw new EnvDrift(missing, extra);
|
|
102
115
|
}
|
|
103
116
|
/**
|
|
104
|
-
* The twelve variables the deployed app runs on,
|
|
117
|
+
* The twelve variables the deployed app runs on, less any of the five optional ones — the two
|
|
118
|
+
* provider keys and the three Langfuse variables — that nothing provisioned.
|
|
105
119
|
*
|
|
106
120
|
* Generates `BETTER_AUTH_SECRET` on first sight and records it: it is the one value in the list
|
|
107
121
|
* that no provider hands back, so a run that did not persist it would lock every existing session
|
|
@@ -136,10 +150,12 @@ export async function buildAppEnvs(context) {
|
|
|
136
150
|
{ key: "SMTP_URL", value: config.HF_SMTP_URL },
|
|
137
151
|
{ key: "EMAIL_FROM", value: config.HF_EMAIL_FROM },
|
|
138
152
|
{ key: "SENTRY_DSN", value: context.state.state.sentryDsn ?? "" },
|
|
139
|
-
{ key: "LANGFUSE_BASE_URL", value: config.HF_LANGFUSE_URL },
|
|
140
|
-
{ key: "LANGFUSE_PUBLIC_KEY", value: langfuse.publicKey ?? "" },
|
|
141
|
-
{ key: "LANGFUSE_SECRET_KEY", value: langfuse.secretKey ?? "" },
|
|
142
153
|
];
|
|
154
|
+
// Omitted outright when the langfuse step reused nothing and created nothing: an empty trio
|
|
155
|
+
// would deploy the same telemetry — none — while reading as configured in Coolify's UI.
|
|
156
|
+
if (langfuse.publicKey !== undefined && langfuse.secretKey !== undefined) {
|
|
157
|
+
envs.push({ key: "LANGFUSE_BASE_URL", value: config.HF_LANGFUSE_URL }, { key: "LANGFUSE_PUBLIC_KEY", value: langfuse.publicKey }, { key: "LANGFUSE_SECRET_KEY", value: langfuse.secretKey });
|
|
158
|
+
}
|
|
143
159
|
for (const [key, value] of providerKeys(context.config))
|
|
144
160
|
envs.push({ key, value });
|
|
145
161
|
return envs;
|
|
@@ -8,5 +8,10 @@ import type { CloudStepContext } from "./context.js";
|
|
|
8
8
|
* once, at creation, and the only copy is the state file this run may not have — so a new key is
|
|
9
9
|
* created and the old ones keep working, which costs an unused key and never an app that cannot
|
|
10
10
|
* authenticate.
|
|
11
|
+
*
|
|
12
|
+
* Creating a project needs an organization-scoped key, which is a paid-plan feature: without one
|
|
13
|
+
* the step falls back to the project key pair the operator configured, and without that to a
|
|
14
|
+
* warning. Both fallbacks record the step — an app with no tracing is a deployed app, and only
|
|
15
|
+
* the operator can decide otherwise.
|
|
11
16
|
*/
|
|
12
17
|
export declare const langfuseStep: Step<CloudStepContext>;
|
|
@@ -2,6 +2,12 @@ import { requireOperatorConfig } from "../config.js";
|
|
|
2
2
|
import { LangfuseClient } from "../providers/langfuse.js";
|
|
3
3
|
/** Langfuse keeps data indefinitely at 0, and any other value needs a paid entitlement. */
|
|
4
4
|
const RETENTION_DAYS = 0;
|
|
5
|
+
/** Said in the warning and again in the closing checklist, so neither run nor log has to be read. */
|
|
6
|
+
const NOT_CONFIGURED = "Langfuse tracing is not configured: neither HF_LANGFUSE_ORG_KEY nor an " +
|
|
7
|
+
"HF_LANGFUSE_PUBLIC_KEY/HF_LANGFUSE_SECRET_KEY pair is set, so LANGFUSE_BASE_URL, " +
|
|
8
|
+
"LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY were not sent to Coolify and the app records no " +
|
|
9
|
+
"traces. To add them later, take a project's key pair from Langfuse → project settings → API " +
|
|
10
|
+
"keys, set all three in Coolify's environment for this application, and redeploy.";
|
|
5
11
|
/**
|
|
6
12
|
* The app's Langfuse project and a key pair for it.
|
|
7
13
|
*
|
|
@@ -10,11 +16,20 @@ const RETENTION_DAYS = 0;
|
|
|
10
16
|
* once, at creation, and the only copy is the state file this run may not have — so a new key is
|
|
11
17
|
* created and the old ones keep working, which costs an unused key and never an app that cannot
|
|
12
18
|
* authenticate.
|
|
19
|
+
*
|
|
20
|
+
* Creating a project needs an organization-scoped key, which is a paid-plan feature: without one
|
|
21
|
+
* the step falls back to the project key pair the operator configured, and without that to a
|
|
22
|
+
* warning. Both fallbacks record the step — an app with no tracing is a deployed app, and only
|
|
23
|
+
* the operator can decide otherwise.
|
|
13
24
|
*/
|
|
14
25
|
export const langfuseStep = {
|
|
15
26
|
name: "langfuse",
|
|
16
27
|
run: async (context) => {
|
|
17
28
|
const { names } = context;
|
|
29
|
+
if ((context.config.HF_LANGFUSE_ORG_KEY ?? "") === "") {
|
|
30
|
+
await reuseOrSkip(context);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
18
33
|
const required = requireOperatorConfig(context.config, ["HF_LANGFUSE_URL", "HF_LANGFUSE_ORG_KEY"], { env: context.env });
|
|
19
34
|
const langfuse = new LangfuseClient({
|
|
20
35
|
url: required.HF_LANGFUSE_URL,
|
|
@@ -33,3 +48,24 @@ export const langfuseStep = {
|
|
|
33
48
|
});
|
|
34
49
|
},
|
|
35
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* The two paths without an org key: the operator's own project key pair, or nothing.
|
|
53
|
+
*
|
|
54
|
+
* No request either way — a project-scoped pair cannot list or create projects, so there is
|
|
55
|
+
* nothing to ask Langfuse that would not fail.
|
|
56
|
+
*/
|
|
57
|
+
async function reuseOrSkip(context) {
|
|
58
|
+
const { names, config } = context;
|
|
59
|
+
const publicKey = config.HF_LANGFUSE_PUBLIC_KEY ?? "";
|
|
60
|
+
const secretKey = config.HF_LANGFUSE_SECRET_KEY ?? "";
|
|
61
|
+
if (publicKey === "" || secretKey === "") {
|
|
62
|
+
context.io.out(`WARNING: ${names.given}: ${NOT_CONFIGURED}`);
|
|
63
|
+
context.checklist.push(NOT_CONFIGURED);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// The public key names the project without being a secret, which is the only identifier this
|
|
67
|
+
// path has: nothing here may ask Langfuse what the project is called.
|
|
68
|
+
context.io.out(`${names.given}: reusing the configured Langfuse project keys (${publicKey}) — every app ` +
|
|
69
|
+
"configured with them traces into that one project");
|
|
70
|
+
await context.state.patch({ langfuse: { publicKey, secretKey } });
|
|
71
|
+
}
|
package/dist/cloud-steps/repo.js
CHANGED
|
@@ -4,6 +4,14 @@ import { ProviderError } from "../providers/http.js";
|
|
|
4
4
|
import { gitHead, mustRun, short, StepFailed } from "./context.js";
|
|
5
5
|
/** One page of installations, and of an installation's repositories. */
|
|
6
6
|
const PER_PAGE = 100;
|
|
7
|
+
/**
|
|
8
|
+
* What GitHub answers a token that may not list installations.
|
|
9
|
+
*
|
|
10
|
+
* `GET /user/installations` is documented as a GitHub App user-to-server endpoint, so every
|
|
11
|
+
* classic PAT, fine-grained PAT and OAuth token — which is what `HF_GITHUB_TOKEN` is — is refused:
|
|
12
|
+
* 403 for a `gh` OAuth token, and 401/404 for the other ways a token can be told no.
|
|
13
|
+
*/
|
|
14
|
+
const CANNOT_LIST = new Set([401, 403, 404]);
|
|
7
15
|
/**
|
|
8
16
|
* The token reaches `git` through the child's environment alone.
|
|
9
17
|
*
|
|
@@ -85,7 +93,7 @@ export const repoStep = {
|
|
|
85
93
|
env: gitAuthEnv(required.HF_GITHUB_TOKEN),
|
|
86
94
|
});
|
|
87
95
|
}
|
|
88
|
-
await
|
|
96
|
+
await checkAppsInstalled(context, github, githubAppSlugs(context.config), fullName);
|
|
89
97
|
await context.state.patch({ repo: fullName });
|
|
90
98
|
},
|
|
91
99
|
};
|
|
@@ -95,9 +103,25 @@ export const repoStep = {
|
|
|
95
103
|
* Coolify cannot deploy from a repository its GitHub App cannot see, and that failure otherwise
|
|
96
104
|
* surfaces as a deployment that clones nothing — so it is asserted here, by name, with the URL
|
|
97
105
|
* that fixes it.
|
|
106
|
+
*
|
|
107
|
+
* Unless the token may not ask at all, which is the usual case: then this degrades to a warning
|
|
108
|
+
* and a checklist line, because the repository has already been created and pushed and there is no
|
|
109
|
+
* second way to read a personal account's installations (organizations have
|
|
110
|
+
* `GET /orgs/{org}/installations`; personal accounts have nothing).
|
|
98
111
|
*/
|
|
99
|
-
async function
|
|
100
|
-
|
|
112
|
+
async function checkAppsInstalled(context, github, slugs, fullName) {
|
|
113
|
+
let installations;
|
|
114
|
+
try {
|
|
115
|
+
installations = await allInstallations(github);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!(error instanceof ProviderError) || !CANNOT_LIST.has(error.status))
|
|
119
|
+
throw error;
|
|
120
|
+
const note = unverifiedNote(slugs, fullName, error.status);
|
|
121
|
+
context.io.out(`WARNING: ${context.names.given}: ${note}`);
|
|
122
|
+
context.checklist.push(note);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
101
125
|
for (const slug of slugs) {
|
|
102
126
|
const installation = installations.find((candidate) => candidate.app_slug === slug);
|
|
103
127
|
if (installation === undefined) {
|
|
@@ -110,6 +134,17 @@ async function assertAppsInstalled(github, slugs, fullName) {
|
|
|
110
134
|
}
|
|
111
135
|
}
|
|
112
136
|
}
|
|
137
|
+
/** The one thing left to the operator when the installations could not be listed. */
|
|
138
|
+
function unverifiedNote(slugs, fullName, status) {
|
|
139
|
+
const apps = slugs
|
|
140
|
+
.map((slug) => `${slug} (https://github.com/apps/${slug}/installations/new)`)
|
|
141
|
+
.join(", ");
|
|
142
|
+
return (`the GitHub App installations on ${fullName} could not be verified with this token — ` +
|
|
143
|
+
`listing them needs a GitHub App user-to-server token and GitHub answered HTTP ` +
|
|
144
|
+
`${String(status)}. Check by hand that each of these is installed on the repository, or on ` +
|
|
145
|
+
`All repositories: ${apps}. Coolify's first deploy clones an empty repository if its app ` +
|
|
146
|
+
`cannot see this one.`);
|
|
147
|
+
}
|
|
113
148
|
async function allInstallations(github) {
|
|
114
149
|
const found = [];
|
|
115
150
|
for (let page = 1;; page += 1) {
|
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_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_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_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>>;
|
|
@@ -44,6 +44,18 @@ export declare function loadOperatorConfig(options?: LoadOperatorConfigOptions):
|
|
|
44
44
|
* got, and an operator who fixes one key per run pays for a partly-provisioned app each time.
|
|
45
45
|
*/
|
|
46
46
|
export declare function requireOperatorConfig<Key extends ConfigKey>(config: OperatorConfig, keys: readonly Key[], options?: LoadOperatorConfigOptions): Record<Key, string>;
|
|
47
|
+
/** What `HF_PG_ADMIN_USER` defaults to: the role a stock Postgres image creates. */
|
|
48
|
+
export declare const DEFAULT_PG_ADMIN_USER = "postgres";
|
|
49
|
+
/** The cluster superuser to log in as — Coolify's `POSTGRES_USER`, which need not be `postgres`. */
|
|
50
|
+
export declare function pgAdminUser(config: OperatorConfig): string;
|
|
51
|
+
/**
|
|
52
|
+
* What Coolify may have called the Postgres container on the box, in the order to try them.
|
|
53
|
+
*
|
|
54
|
+
* A standalone Postgres resource runs in a container named for the bare uuid; a database attached
|
|
55
|
+
* to a service gets `postgresql-<uuid>`. Both are asked about rather than one being guessed at,
|
|
56
|
+
* and `HF_DB_CONTAINER` replaces the pair outright. Empty when neither key is set.
|
|
57
|
+
*/
|
|
58
|
+
export declare function postgresContainers(config: OperatorConfig): readonly string[];
|
|
47
59
|
/**
|
|
48
60
|
* `HF_GITHUB_APP_SLUGS` as a list: split on commas, trimmed, empties dropped.
|
|
49
61
|
*
|
package/dist/config.js
CHANGED
|
@@ -17,6 +17,15 @@ export const CONFIG_KEYS = [
|
|
|
17
17
|
// names the container after the database's uuid, so `HF_COOLIFY_POSTGRES_UUID` is the default a
|
|
18
18
|
// caller falls back to, and this is how a box that disagrees is told to us rather than guessed.
|
|
19
19
|
"HF_DB_HOST_INTERNAL",
|
|
20
|
+
// The Postgres container's name on the box, which is what `docker inspect` is asked about when
|
|
21
|
+
// the box's loopback has no 5432 listener. Coolify names it for the bare uuid or
|
|
22
|
+
// `postgresql-<uuid>` depending on how the database was created, so `postgresContainers()`
|
|
23
|
+
// derives both from `HF_COOLIFY_POSTGRES_UUID` and this replaces the pair.
|
|
24
|
+
"HF_DB_CONTAINER",
|
|
25
|
+
// The cluster superuser to log in as. Coolify creates the cluster with its own `POSTGRES_USER`,
|
|
26
|
+
// and on the X1 box that role is not `postgres` — `psql -U postgres` there fails with
|
|
27
|
+
// `role "postgres" does not exist`. The password stays in `PGPASSWORD`, never in this file.
|
|
28
|
+
"HF_PG_ADMIN_USER",
|
|
20
29
|
"HF_SSH_HOST",
|
|
21
30
|
"HF_CLOUDFLARE_TOKEN",
|
|
22
31
|
"HF_CLOUDFLARE_ZONE_ID",
|
|
@@ -29,7 +38,14 @@ export const CONFIG_KEYS = [
|
|
|
29
38
|
"HF_SENTRY_TOKEN",
|
|
30
39
|
"HF_SENTRY_ORG",
|
|
31
40
|
"HF_LANGFUSE_URL",
|
|
41
|
+
// Optional, and all three are: an organization-scoped key pair creates the app its own project,
|
|
42
|
+
// but it is a paid-plan feature, so a Hobby account instead names an existing project's key pair
|
|
43
|
+
// here and every app it provisions traces into that one project. With none of them set the
|
|
44
|
+
// langfuse step records nothing and the three LANGFUSE_* variables are omitted rather than sent
|
|
45
|
+
// empty — an empty value in Coolify's UI reads as configured.
|
|
32
46
|
"HF_LANGFUSE_ORG_KEY",
|
|
47
|
+
"HF_LANGFUSE_PUBLIC_KEY",
|
|
48
|
+
"HF_LANGFUSE_SECRET_KEY",
|
|
33
49
|
"HF_BOX_IP",
|
|
34
50
|
"HF_SMTP_URL",
|
|
35
51
|
"HF_EMAIL_FROM",
|
|
@@ -118,6 +134,27 @@ export function requireOperatorConfig(config, keys, options = {}) {
|
|
|
118
134
|
}
|
|
119
135
|
return required;
|
|
120
136
|
}
|
|
137
|
+
/** What `HF_PG_ADMIN_USER` defaults to: the role a stock Postgres image creates. */
|
|
138
|
+
export const DEFAULT_PG_ADMIN_USER = "postgres";
|
|
139
|
+
/** The cluster superuser to log in as — Coolify's `POSTGRES_USER`, which need not be `postgres`. */
|
|
140
|
+
export function pgAdminUser(config) {
|
|
141
|
+
const user = config.HF_PG_ADMIN_USER;
|
|
142
|
+
return user === undefined || user === "" ? DEFAULT_PG_ADMIN_USER : user;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* What Coolify may have called the Postgres container on the box, in the order to try them.
|
|
146
|
+
*
|
|
147
|
+
* A standalone Postgres resource runs in a container named for the bare uuid; a database attached
|
|
148
|
+
* to a service gets `postgresql-<uuid>`. Both are asked about rather than one being guessed at,
|
|
149
|
+
* and `HF_DB_CONTAINER` replaces the pair outright. Empty when neither key is set.
|
|
150
|
+
*/
|
|
151
|
+
export function postgresContainers(config) {
|
|
152
|
+
const override = config.HF_DB_CONTAINER;
|
|
153
|
+
if (override !== undefined && override !== "")
|
|
154
|
+
return [override];
|
|
155
|
+
const uuid = config.HF_COOLIFY_POSTGRES_UUID;
|
|
156
|
+
return uuid === undefined || uuid === "" ? [] : [uuid, `postgresql-${uuid}`];
|
|
157
|
+
}
|
|
121
158
|
/**
|
|
122
159
|
* `HF_GITHUB_APP_SLUGS` as a list: split on commas, trimmed, empties dropped.
|
|
123
160
|
*
|
package/dist/database.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Runner } from "./runner.js";
|
|
2
2
|
/** Rows as strings, the one shape both transports can produce without inventing types. */
|
|
3
3
|
export interface QueryResult {
|
|
4
4
|
rows: string[][];
|
|
@@ -13,13 +13,22 @@ export type DatabaseTransport = "tunnel" | "docker-exec";
|
|
|
13
13
|
*
|
|
14
14
|
* `tunnel` is the default, and the only transport that can carry the whole of E2: it hands out
|
|
15
15
|
* a libpq URL, which is what `provisionRoles()` — a `pg` client, in `@hyperfixation/db` — takes.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* container, so `CREATE DATABASE`, the extensions and a password
|
|
19
|
-
* is no address for a client library to dial and `adminUrl` is
|
|
16
|
+
* Its far end is the box's loopback where the port is published and the container's own address
|
|
17
|
+
* on the docker network where it is not. `docker-exec` is the last resort: it runs the same SQL
|
|
18
|
+
* through `psql` inside the container, so `CREATE DATABASE`, the extensions and a password
|
|
19
|
+
* rotation all work, but there is no address for a client library to dial and `adminUrl` is
|
|
20
|
+
* `undefined`.
|
|
20
21
|
*/
|
|
21
22
|
export interface Database {
|
|
22
23
|
readonly kind: DatabaseTransport;
|
|
24
|
+
/**
|
|
25
|
+
* Where the **box** reaches this cluster, for anything that runs there rather than here —
|
|
26
|
+
* `pg_restore`, in E7. `undefined` when the transport has no address at all.
|
|
27
|
+
*/
|
|
28
|
+
readonly boxAddress?: {
|
|
29
|
+
host: string;
|
|
30
|
+
port: number;
|
|
31
|
+
};
|
|
23
32
|
/** A libpq URL onto `databaseName`, or `undefined` when the transport has no address. */
|
|
24
33
|
adminUrl(databaseName?: string): string | undefined;
|
|
25
34
|
query(sql: string, options?: QueryOptions): Promise<QueryResult>;
|
|
@@ -47,18 +56,39 @@ export interface AdminCredentials {
|
|
|
47
56
|
}
|
|
48
57
|
export interface OpenDatabaseOptions {
|
|
49
58
|
admin: AdminCredentials;
|
|
50
|
-
/**
|
|
59
|
+
/** The port Postgres listens on, wherever it is reached. */
|
|
51
60
|
remotePort?: number;
|
|
52
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* What the Coolify Postgres container may be called, in the order to try them; Coolify's own
|
|
63
|
+
* naming depends on how the database was created. The address of the first one that exists is
|
|
64
|
+
* what the tunnel forwards to when the box's loopback has no listener, and `dockerExec` runs
|
|
65
|
+
* `psql` inside it. Omit to have neither, and the loopback is then the only route.
|
|
66
|
+
*/
|
|
67
|
+
containers?: readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* One container, appended to `containers`.
|
|
70
|
+
*
|
|
71
|
+
* @deprecated Coolify's naming depends on how the database was created, so a caller that knows
|
|
72
|
+
* only the uuid has two names to try; pass `containers`. Removed in 0.2.0.
|
|
73
|
+
*/
|
|
53
74
|
container?: string;
|
|
75
|
+
/** Last resort when no address carries a query: `psql` inside the container. Default false. */
|
|
76
|
+
dockerExec?: boolean;
|
|
54
77
|
}
|
|
55
78
|
export declare const DEFAULT_POSTGRES_PORT = 5432;
|
|
56
79
|
/**
|
|
57
|
-
* Opens the cluster over `runner
|
|
80
|
+
* Opens the cluster over `runner`: the box's loopback, else the container, else `docker exec`.
|
|
58
81
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
82
|
+
* Coolify publishes nothing for its Postgres — `docker inspect` reports `{"5432/tcp": null}`, so
|
|
83
|
+
* the box's `127.0.0.1:5432` is not a listener and forwarding to it can never work. The
|
|
84
|
+
* container's address on the `coolify` network is the route in: the box host routes to it, and
|
|
85
|
+
* `ssh -L localPort:<containerIP>:5432` makes the box the hop. Publishing the port would bind
|
|
86
|
+
* every interface, which is not a trade worth making for a forward that already works.
|
|
87
|
+
*
|
|
88
|
+
* The loopback is still tried first and costs one `ssh` when it fails, because some setups do
|
|
89
|
+
* publish it. Each probe is a real `SELECT 1` rather than a port check: an `ssh -L` forward
|
|
90
|
+
* accepts locally and only then discovers that nothing is listening on the far side, so a forward
|
|
91
|
+
* to an unpublished port looks healthy until the first query.
|
|
62
92
|
*/
|
|
63
93
|
export declare function openDatabase(runner: Runner, options: OpenDatabaseOptions): Promise<Database>;
|
|
64
94
|
/** The cluster at a URL this process can already dial — a test's Postgres, or a live tunnel. */
|
package/dist/database.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { isIPv4 } from "node:net";
|
|
1
2
|
import { Client } from "pg";
|
|
3
|
+
import { shellQuote, TUNNEL_LOOPBACK } from "./runner.js";
|
|
2
4
|
export class DatabaseTransportError extends Error {
|
|
3
5
|
transport;
|
|
4
6
|
constructor(transport, message, options) {
|
|
@@ -24,35 +26,116 @@ const FIELD_SEPARATOR = "";
|
|
|
24
26
|
export const DEFAULT_POSTGRES_PORT = 5432;
|
|
25
27
|
const DEFAULT_ADMIN_DATABASE = "postgres";
|
|
26
28
|
/**
|
|
27
|
-
*
|
|
29
|
+
* Docker's network→IP map for one container, as whitespace-separated `network=ip` pairs.
|
|
28
30
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
31
|
+
* A Go template rather than `--format json` and a parse: the output is one flat line, so nothing
|
|
32
|
+
* about it depends on which docker version the box has.
|
|
33
|
+
*/
|
|
34
|
+
const DOCKER_NETWORKS_FORMAT = "{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}";
|
|
35
|
+
/** The network Coolify attaches its services to, and the one the box host can route to. */
|
|
36
|
+
const COOLIFY_NETWORK = "coolify";
|
|
37
|
+
/**
|
|
38
|
+
* Opens the cluster over `runner`: the box's loopback, else the container, else `docker exec`.
|
|
39
|
+
*
|
|
40
|
+
* Coolify publishes nothing for its Postgres — `docker inspect` reports `{"5432/tcp": null}`, so
|
|
41
|
+
* the box's `127.0.0.1:5432` is not a listener and forwarding to it can never work. The
|
|
42
|
+
* container's address on the `coolify` network is the route in: the box host routes to it, and
|
|
43
|
+
* `ssh -L localPort:<containerIP>:5432` makes the box the hop. Publishing the port would bind
|
|
44
|
+
* every interface, which is not a trade worth making for a forward that already works.
|
|
45
|
+
*
|
|
46
|
+
* The loopback is still tried first and costs one `ssh` when it fails, because some setups do
|
|
47
|
+
* publish it. Each probe is a real `SELECT 1` rather than a port check: an `ssh -L` forward
|
|
48
|
+
* accepts locally and only then discovers that nothing is listening on the far side, so a forward
|
|
49
|
+
* to an unpublished port looks healthy until the first query.
|
|
32
50
|
*/
|
|
33
51
|
export async function openDatabase(runner, options) {
|
|
34
52
|
const remotePort = options.remotePort ?? DEFAULT_POSTGRES_PORT;
|
|
53
|
+
const loopback = await tryTunnel(runner, options.admin, remotePort, TUNNEL_LOOPBACK);
|
|
54
|
+
if ("database" in loopback)
|
|
55
|
+
return loopback.database;
|
|
56
|
+
const candidates = [
|
|
57
|
+
...(options.containers ?? []),
|
|
58
|
+
...(options.container === undefined ? [] : [options.container]),
|
|
59
|
+
];
|
|
60
|
+
if (candidates.length === 0) {
|
|
61
|
+
throw new DatabaseTransportError("tunnel", `could not reach Postgres on ${TUNNEL_LOOPBACK}:${String(remotePort)} on the box, and no ` +
|
|
62
|
+
"container was named to discover an address on the docker network", { cause: loopback.failure });
|
|
63
|
+
}
|
|
64
|
+
const found = await containerAddress(runner, candidates);
|
|
65
|
+
const direct = await tryTunnel(runner, options.admin, remotePort, found.address);
|
|
66
|
+
if ("database" in direct)
|
|
67
|
+
return direct.database;
|
|
68
|
+
if (options.dockerExec !== true) {
|
|
69
|
+
throw new DatabaseTransportError("tunnel", `could not reach Postgres on ${TUNNEL_LOOPBACK}:${String(remotePort)} on the box, nor on ` +
|
|
70
|
+
`${found.address}:${String(remotePort)}, which is where ${found.container} answers on ` +
|
|
71
|
+
"the docker network", { cause: direct.failure });
|
|
72
|
+
}
|
|
73
|
+
return dockerExecDatabase(runner, found.container, options.admin);
|
|
74
|
+
}
|
|
75
|
+
/** The cluster at a URL this process can already dial — a test's Postgres, or a live tunnel. */
|
|
76
|
+
export function openDatabaseUrl(adminUrl) {
|
|
77
|
+
return tunnelDatabase(adminUrl, undefined, undefined);
|
|
78
|
+
}
|
|
79
|
+
async function tryTunnel(runner, admin, remotePort, remoteHost) {
|
|
35
80
|
let tunnel;
|
|
36
81
|
try {
|
|
37
|
-
tunnel = await runner.tunnel(remotePort);
|
|
38
|
-
const database = tunnelDatabase(adminUrlOf(
|
|
82
|
+
tunnel = await runner.tunnel(remotePort, remoteHost);
|
|
83
|
+
const database = tunnelDatabase(adminUrlOf(admin, tunnel.localPort), tunnel, {
|
|
84
|
+
host: remoteHost,
|
|
85
|
+
port: remotePort,
|
|
86
|
+
});
|
|
39
87
|
await database.query("SELECT 1");
|
|
40
|
-
return database;
|
|
88
|
+
return { database };
|
|
41
89
|
}
|
|
42
|
-
catch (
|
|
90
|
+
catch (failure) {
|
|
43
91
|
await tunnel?.close();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
92
|
+
return { failure };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The first of `containers` that exists, and its own address, asked of the box.
|
|
97
|
+
*
|
|
98
|
+
* The `coolify` network by name, because a Coolify service also sits on a per-service network
|
|
99
|
+
* that only its own stack is on; the first address is the fallback for a box that names its
|
|
100
|
+
* network something else. Every candidate that failed is reported, because which name a database
|
|
101
|
+
* got is a fact about how it was created and the operator is the one who knows it.
|
|
102
|
+
*/
|
|
103
|
+
async function containerAddress(runner, containers) {
|
|
104
|
+
const problems = [];
|
|
105
|
+
for (const container of containers) {
|
|
106
|
+
const command = ["docker", "inspect", "-f", DOCKER_NETWORKS_FORMAT, container];
|
|
107
|
+
const result = await runner.exec(command);
|
|
108
|
+
if (result.code !== 0) {
|
|
109
|
+
problems.push(`${container}: ${shellQuote(command)} exited ${String(result.code)}: ` +
|
|
110
|
+
result.stderr.trim());
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const address = coolifyAddress(result.stdout);
|
|
114
|
+
if (address === undefined) {
|
|
115
|
+
problems.push(`${container}: no IPv4 address on any docker network, ${shellQuote(command)} printed ` +
|
|
116
|
+
JSON.stringify(result.stdout.trim()));
|
|
117
|
+
continue;
|
|
47
118
|
}
|
|
119
|
+
return { container, address };
|
|
48
120
|
}
|
|
49
|
-
|
|
121
|
+
throw new DatabaseTransportError("tunnel", `no Postgres container on the box under any name tried (${containers.join(", ")}): ` +
|
|
122
|
+
problems.join("; "));
|
|
50
123
|
}
|
|
51
|
-
/** The
|
|
52
|
-
|
|
53
|
-
|
|
124
|
+
/** The `coolify` network's address in `docker inspect`'s output, else the first one there is. */
|
|
125
|
+
function coolifyAddress(stdout) {
|
|
126
|
+
const networks = stdout
|
|
127
|
+
.split(/\s+/)
|
|
128
|
+
.filter((pair) => pair.includes("="))
|
|
129
|
+
.map((pair) => ({
|
|
130
|
+
network: pair.slice(0, pair.indexOf("=")),
|
|
131
|
+
address: pair.slice(pair.indexOf("=") + 1),
|
|
132
|
+
}))
|
|
133
|
+
// An IPv4 literal and nothing else: this goes into an `ssh -L` field, and a container with no
|
|
134
|
+
// address on a network reports the key with an empty value.
|
|
135
|
+
.filter((entry) => isIPv4(entry.address));
|
|
136
|
+
return (networks.find((entry) => entry.network === COOLIFY_NETWORK) ?? networks[0])?.address;
|
|
54
137
|
}
|
|
55
|
-
function tunnelDatabase(adminUrl, tunnel) {
|
|
138
|
+
function tunnelDatabase(adminUrl, tunnel, boxAddress) {
|
|
56
139
|
const clients = new Map();
|
|
57
140
|
const clientFor = async (databaseName) => {
|
|
58
141
|
const url = withDatabase(adminUrl, databaseName);
|
|
@@ -66,6 +149,7 @@ function tunnelDatabase(adminUrl, tunnel) {
|
|
|
66
149
|
};
|
|
67
150
|
return {
|
|
68
151
|
kind: "tunnel",
|
|
152
|
+
boxAddress,
|
|
69
153
|
adminUrl: (databaseName) => withDatabase(adminUrl, databaseName),
|
|
70
154
|
query: async (sql, queryOptions) => {
|
|
71
155
|
const client = await clientFor(queryOptions?.database);
|
package/dist/doctor.d.ts
CHANGED
|
@@ -55,13 +55,14 @@ 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
|
*
|
|
@@ -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,
|
|
@@ -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);
|
package/dist/runner.d.ts
CHANGED
|
@@ -22,8 +22,13 @@ export interface Tunnel {
|
|
|
22
22
|
*/
|
|
23
23
|
export interface Runner {
|
|
24
24
|
exec(command: readonly string[], options?: ExecOptions): Promise<ExecResult>;
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Forwards a local port to `<remoteHost>:<remotePort>` as the far side sees it.
|
|
27
|
+
*
|
|
28
|
+
* `remoteHost` defaults to the far side's own loopback; it is an address on a network the far
|
|
29
|
+
* side can route to, which is how a container that publishes nothing is still reachable.
|
|
30
|
+
*/
|
|
31
|
+
tunnel(remotePort: number, remoteHost?: string): Promise<Tunnel>;
|
|
27
32
|
}
|
|
28
33
|
export declare class RunnerError extends Error {
|
|
29
34
|
constructor(message: string, options?: {
|
|
@@ -32,7 +37,9 @@ export declare class RunnerError extends Error {
|
|
|
32
37
|
}
|
|
33
38
|
/** The argv `exec` runs, `ssh` excluded — the array the test asserts against. */
|
|
34
39
|
export declare function sshExecArgv(host: string, command: readonly string[]): string[];
|
|
35
|
-
export declare function sshTunnelArgv(host: string, localPort: number, remotePort: number): string[];
|
|
40
|
+
export declare function sshTunnelArgv(host: string, localPort: number, remotePort: number, remoteHost?: string): string[];
|
|
41
|
+
/** Where a forward lands when the caller names no host: the far side's own loopback. */
|
|
42
|
+
export declare const TUNNEL_LOOPBACK = "127.0.0.1";
|
|
36
43
|
/** Single-quotes one word for a POSIX remote shell. */
|
|
37
44
|
export declare function shellQuote(command: readonly string[]): string;
|
|
38
45
|
export interface SshRunnerOptions {
|
package/dist/runner.js
CHANGED
|
@@ -37,17 +37,20 @@ export function sshExecArgv(host, command) {
|
|
|
37
37
|
// re-quoted for that shell; nothing else in this file ever builds a shell word.
|
|
38
38
|
return ["-T", ...SSH_OPTIONS, host, shellQuote(command)];
|
|
39
39
|
}
|
|
40
|
-
export function sshTunnelArgv(host, localPort, remotePort) {
|
|
40
|
+
export function sshTunnelArgv(host, localPort, remotePort, remoteHost = TUNNEL_LOOPBACK) {
|
|
41
41
|
assertHost(host);
|
|
42
|
+
assertTunnelHost(remoteHost);
|
|
42
43
|
return [
|
|
43
44
|
"-N",
|
|
44
45
|
"-T",
|
|
45
46
|
...SSH_OPTIONS,
|
|
46
47
|
"-L",
|
|
47
|
-
`${String(localPort)}
|
|
48
|
+
`${String(localPort)}:${remoteHost}:${String(remotePort)}`,
|
|
48
49
|
host,
|
|
49
50
|
];
|
|
50
51
|
}
|
|
52
|
+
/** Where a forward lands when the caller names no host: the far side's own loopback. */
|
|
53
|
+
export const TUNNEL_LOOPBACK = "127.0.0.1";
|
|
51
54
|
/** Single-quotes one word for a POSIX remote shell. */
|
|
52
55
|
export function shellQuote(command) {
|
|
53
56
|
return command.map((word) => `'${word.replaceAll("'", `'\\''`)}'`).join(" ");
|
|
@@ -59,9 +62,9 @@ export function createSshRunner(options) {
|
|
|
59
62
|
assertHost(options.host);
|
|
60
63
|
return {
|
|
61
64
|
exec: async (command, execOptions) => await spawnCollecting(ssh, sshExecArgv(options.host, command), execOptions),
|
|
62
|
-
tunnel: async (remotePort) => {
|
|
65
|
+
tunnel: async (remotePort, remoteHost = TUNNEL_LOOPBACK) => {
|
|
63
66
|
const localPort = await freeLocalPort();
|
|
64
|
-
const child = spawn(ssh, sshTunnelArgv(options.host, localPort, remotePort), {
|
|
67
|
+
const child = spawn(ssh, sshTunnelArgv(options.host, localPort, remotePort, remoteHost), {
|
|
65
68
|
stdio: ["ignore", "ignore", "pipe"],
|
|
66
69
|
});
|
|
67
70
|
let stderr = "";
|
|
@@ -75,7 +78,7 @@ export function createSshRunner(options) {
|
|
|
75
78
|
catch (cause) {
|
|
76
79
|
child.kill("SIGTERM");
|
|
77
80
|
await exited;
|
|
78
|
-
throw new RunnerError(`ssh -L ${String(localPort)}
|
|
81
|
+
throw new RunnerError(`ssh -L ${String(localPort)}:${remoteHost}:${String(remotePort)} never became ready` +
|
|
79
82
|
(stderr === "" ? "" : `: ${stderr.trim()}`), { cause });
|
|
80
83
|
}
|
|
81
84
|
return {
|
|
@@ -131,14 +134,20 @@ async function spawnCollecting(bin, args, options = {}) {
|
|
|
131
134
|
child.stderr?.on("data", (chunk) => {
|
|
132
135
|
stderr += chunk.toString("utf8");
|
|
133
136
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
const closed = new Promise((resolve, reject) => {
|
|
138
|
+
// A child that exits before reading its stdin (`true`, a refused ssh) makes the write fail with
|
|
139
|
+
// EPIPE; the exit code already says what happened. Any other stdin error is a real failure.
|
|
140
|
+
child.stdin?.on("error", (error) => {
|
|
141
|
+
if (error.code === "EPIPE")
|
|
142
|
+
return;
|
|
143
|
+
child.kill();
|
|
144
|
+
reject(error);
|
|
145
|
+
});
|
|
139
146
|
child.once("error", reject);
|
|
140
147
|
child.once("close", (exitCode) => resolve(exitCode));
|
|
141
148
|
});
|
|
149
|
+
child.stdin?.end(options.input ?? "");
|
|
150
|
+
const code = await closed;
|
|
142
151
|
return { code, stdout, stderr };
|
|
143
152
|
}
|
|
144
153
|
/**
|
|
@@ -192,6 +201,19 @@ async function canConnect(port) {
|
|
|
192
201
|
socket.once("timeout", () => done(false));
|
|
193
202
|
});
|
|
194
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* The far-side end of a `-L` forward, which is a bare address or hostname and nothing else.
|
|
206
|
+
*
|
|
207
|
+
* It arrives from `docker inspect` on the box rather than from the operator, and `-L` takes its
|
|
208
|
+
* three fields colon-separated, so a value carrying a colon or a space would silently become a
|
|
209
|
+
* different forward than the one asked for.
|
|
210
|
+
*/
|
|
211
|
+
function assertTunnelHost(remoteHost) {
|
|
212
|
+
if (!TUNNEL_HOST.test(remoteHost)) {
|
|
213
|
+
throw new RunnerError(`a tunnel's remote host must match ${TUNNEL_HOST.source}, got ${JSON.stringify(remoteHost)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const TUNNEL_HOST = /^[A-Za-z0-9._-]+$/;
|
|
195
217
|
function assertHost(host) {
|
|
196
218
|
if (!SSH_HOST.test(host)) {
|
|
197
219
|
throw new RunnerError(`HF_SSH_HOST must match ${SSH_HOST.source}, got ${JSON.stringify(host)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperfixation/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "The hf binary and its Turborepo generator templates",
|
|
6
6
|
"repository": {
|
|
@@ -29,15 +29,15 @@
|
|
|
29
29
|
"!dist/test-support/**"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@hyperfixation/auth": "0.1.
|
|
33
|
-
"@hyperfixation/core": "0.1.
|
|
34
|
-
"@hyperfixation/db": "0.1.
|
|
32
|
+
"@hyperfixation/auth": "0.1.2",
|
|
33
|
+
"@hyperfixation/core": "0.1.2",
|
|
34
|
+
"@hyperfixation/db": "0.1.2",
|
|
35
35
|
"giget": "3.3.1",
|
|
36
36
|
"pg": "^8.23.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@hyperfixation/eslint-config": "0.1.
|
|
40
|
-
"@hyperfixation/testing": "0.1.
|
|
39
|
+
"@hyperfixation/eslint-config": "0.1.2",
|
|
40
|
+
"@hyperfixation/testing": "0.1.2",
|
|
41
41
|
"@microsoft/api-extractor": "^7.59.1",
|
|
42
42
|
"@types/pg": "^8.23.1",
|
|
43
43
|
"eslint": "^10.10.0",
|