@hyperfixation/cli 0.1.0 → 0.1.1
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/app.d.ts +15 -2
- package/dist/app.js +4 -2
- package/dist/backup-source.d.ts +47 -0
- package/dist/backup-source.js +107 -0
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.js +1 -1
- package/dist/checklist.d.ts +25 -0
- package/dist/checklist.js +32 -0
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +95 -2
- package/dist/cloud-steps/backup.d.ts +17 -0
- package/dist/cloud-steps/backup.js +40 -0
- package/dist/cloud-steps/context.d.ts +120 -0
- package/dist/cloud-steps/context.js +88 -0
- package/dist/cloud-steps/coolify.d.ts +74 -0
- package/dist/cloud-steps/coolify.js +300 -0
- package/dist/cloud-steps/database.d.ts +12 -0
- package/dist/cloud-steps/database.js +25 -0
- package/dist/cloud-steps/deploy.d.ts +18 -0
- package/dist/cloud-steps/deploy.js +110 -0
- package/dist/cloud-steps/dns.d.ts +11 -0
- package/dist/cloud-steps/dns.js +53 -0
- package/dist/cloud-steps/index.d.ts +21 -0
- package/dist/cloud-steps/index.js +30 -0
- package/dist/cloud-steps/install.d.ts +12 -0
- package/dist/cloud-steps/install.js +53 -0
- package/dist/cloud-steps/langfuse.d.ts +12 -0
- package/dist/cloud-steps/langfuse.js +35 -0
- package/dist/cloud-steps/repo.d.ts +20 -0
- package/dist/cloud-steps/repo.js +163 -0
- package/dist/cloud-steps/sentry.d.ts +13 -0
- package/dist/cloud-steps/sentry.js +55 -0
- package/dist/cloud-steps/template.d.ts +22 -0
- package/dist/cloud-steps/template.js +68 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.js +155 -0
- package/dist/database.d.ts +65 -0
- package/dist/database.js +142 -0
- package/dist/doctor.d.ts +71 -0
- package/dist/doctor.js +310 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -0
- package/dist/migrate.d.ts +11 -0
- package/dist/migrate.js +26 -2
- package/dist/new-cloud.d.ts +126 -0
- package/dist/new-cloud.js +210 -0
- package/dist/new.d.ts +2 -0
- package/dist/new.js +2 -1
- package/dist/providers/cloudflare.d.ts +49 -0
- package/dist/providers/cloudflare.js +27 -0
- package/dist/providers/coolify.d.ts +148 -0
- package/dist/providers/coolify.js +87 -0
- package/dist/providers/github.d.ts +117 -0
- package/dist/providers/github.js +98 -0
- package/dist/providers/http.d.ts +41 -0
- package/dist/providers/http.js +56 -0
- package/dist/providers/langfuse.d.ts +41 -0
- package/dist/providers/langfuse.js +29 -0
- package/dist/providers/sentry.d.ts +31 -0
- package/dist/providers/sentry.js +27 -0
- package/dist/provision-database.d.ts +42 -0
- package/dist/provision-database.js +107 -0
- package/dist/restore-check.d.ts +91 -0
- package/dist/restore-check.js +257 -0
- package/dist/runner.d.ts +65 -0
- package/dist/runner.js +199 -0
- package/dist/secret-file.d.ts +30 -0
- package/dist/secret-file.js +69 -0
- package/dist/state.d.ts +124 -0
- package/dist/state.js +217 -0
- package/dist/status-token.d.ts +2 -0
- package/dist/status-token.js +1 -1
- package/dist/template-source.d.ts +23 -0
- package/dist/template-source.js +23 -0
- package/package.json +10 -7
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createTransport, segment } from "./http.js";
|
|
2
|
+
export const GITHUB_API_URL = "https://api.github.com";
|
|
3
|
+
export class GithubClient {
|
|
4
|
+
request;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.request = createTransport({
|
|
7
|
+
provider: "github",
|
|
8
|
+
baseUrl: options.url ?? GITHUB_API_URL,
|
|
9
|
+
headers: {
|
|
10
|
+
authorization: `Bearer ${options.token}`,
|
|
11
|
+
accept: "application/vnd.github+json",
|
|
12
|
+
"x-github-api-version": "2022-11-28",
|
|
13
|
+
},
|
|
14
|
+
fetch: options.fetch,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Who `HF_GITHUB_OWNER` is: `type` decides between `/user/repos` and `/orgs/{org}/repos`.
|
|
19
|
+
*
|
|
20
|
+
* Unauthenticated-shaped data on purpose — this endpoint answers for any account, so it is the
|
|
21
|
+
* cheapest way to settle the question without assuming the token owns the name.
|
|
22
|
+
*/
|
|
23
|
+
async getUser(username) {
|
|
24
|
+
return await this.request({ method: "GET", path: `/users/${segment(username)}` });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The repository, when there may already be one.
|
|
28
|
+
*
|
|
29
|
+
* A 404 from here is "no such repository **for this token**": the same status covers absent and
|
|
30
|
+
* invisible, so a caller that means to create one must treat it as "create and let the create
|
|
31
|
+
* fail" rather than as proof the name is free.
|
|
32
|
+
*/
|
|
33
|
+
async getRepository(owner, repo) {
|
|
34
|
+
return await this.request({
|
|
35
|
+
method: "GET",
|
|
36
|
+
path: `/repos/${segment(owner)}/${segment(repo)}`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** The repository for an app whose `HF_GITHUB_OWNER` is the token's own account. */
|
|
40
|
+
async createUserRepository(body) {
|
|
41
|
+
return await this.request({ method: "POST", path: "/user/repos", body });
|
|
42
|
+
}
|
|
43
|
+
/** The same, when `HF_GITHUB_OWNER` is an organization instead. */
|
|
44
|
+
async createOrgRepository(org, body) {
|
|
45
|
+
return await this.request({ method: "POST", path: `/orgs/${segment(org)}/repos`, body });
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `hf doctor`'s idea of what the repository says is deployed. `ref` is `heads/main`, and is
|
|
49
|
+
* not percent-encoded: GitHub spells this parameter with the slash as a path separator.
|
|
50
|
+
*/
|
|
51
|
+
async getReference(owner, repo, ref) {
|
|
52
|
+
return await this.request({
|
|
53
|
+
method: "GET",
|
|
54
|
+
path: `/repos/${segment(owner)}/${segment(repo)}/git/ref/${ref}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Open pull requests, unfiltered.
|
|
59
|
+
*
|
|
60
|
+
* `hf doctor` wants the `core-bump/` ones, but GitHub's `head` filter takes a whole
|
|
61
|
+
* `user:branch`, not a prefix, so the prefix match belongs to the caller.
|
|
62
|
+
*/
|
|
63
|
+
async listPullRequests(owner, repo, options = {}) {
|
|
64
|
+
return await this.request({
|
|
65
|
+
method: "GET",
|
|
66
|
+
path: `/repos/${segment(owner)}/${segment(repo)}/pulls`,
|
|
67
|
+
query: { state: options.state, per_page: options.per_page },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The GitHub Apps installed for the token's user, with their slugs.
|
|
72
|
+
*
|
|
73
|
+
* `hf new` asserts both `HF_GITHUB_APP_SLUGS` entries are installed on the new repository —
|
|
74
|
+
* Coolify cannot deploy from a repository its app cannot see, and that failure otherwise
|
|
75
|
+
* surfaces as a deploy that clones nothing.
|
|
76
|
+
*/
|
|
77
|
+
async listInstallations(options = {}) {
|
|
78
|
+
return await this.request({
|
|
79
|
+
method: "GET",
|
|
80
|
+
path: "/user/installations",
|
|
81
|
+
query: { per_page: options.per_page, page: options.page },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Which repositories one installation actually reaches; the other half of that assertion. */
|
|
85
|
+
async listInstallationRepositories(installationId, options = {}) {
|
|
86
|
+
return await this.request({
|
|
87
|
+
method: "GET",
|
|
88
|
+
path: `/user/installations/${segment(String(installationId))}/repositories`,
|
|
89
|
+
query: { per_page: options.per_page, page: options.page },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async getCombinedStatus(owner, repo, ref) {
|
|
93
|
+
return await this.request({
|
|
94
|
+
method: "GET",
|
|
95
|
+
path: `/repos/${segment(owner)}/${segment(repo)}/commits/${segment(ref)}/status`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The one function every client talks to the network through; tests inject their own. */
|
|
2
|
+
export type FetchLike = typeof globalThis.fetch;
|
|
3
|
+
export type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
4
|
+
export interface ProviderRequest {
|
|
5
|
+
method: HttpMethod;
|
|
6
|
+
/** Path under the client's base URL, already interpolated. */
|
|
7
|
+
path: string;
|
|
8
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
9
|
+
body?: unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A provider answered with a status outside 2xx.
|
|
13
|
+
*
|
|
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.
|
|
19
|
+
*/
|
|
20
|
+
export declare class ProviderError extends Error {
|
|
21
|
+
readonly provider: string;
|
|
22
|
+
readonly status: number;
|
|
23
|
+
readonly method: HttpMethod;
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly body: string;
|
|
26
|
+
constructor(provider: string, request: ProviderRequest, status: number, body: string);
|
|
27
|
+
}
|
|
28
|
+
export interface TransportOptions {
|
|
29
|
+
/** Names the provider in errors. */
|
|
30
|
+
provider: string;
|
|
31
|
+
/** Everything before `path`, including any API prefix (`https://coolify.example/api/v1`). */
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
/** Sent on every request — the authorization header, and whatever else the API insists on. */
|
|
34
|
+
headers: Record<string, string>;
|
|
35
|
+
fetch?: FetchLike;
|
|
36
|
+
}
|
|
37
|
+
export type Transport = <Result>(request: ProviderRequest) => Promise<Result>;
|
|
38
|
+
/** Builds the `request` function the clients in this directory are written against. */
|
|
39
|
+
export declare function createTransport(options: TransportOptions): Transport;
|
|
40
|
+
/** Percent-encodes one path segment, so an app name can never escape into the path. */
|
|
41
|
+
export declare function segment(value: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider answered with a status outside 2xx.
|
|
3
|
+
*
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
export class ProviderError extends Error {
|
|
11
|
+
provider;
|
|
12
|
+
status;
|
|
13
|
+
method;
|
|
14
|
+
path;
|
|
15
|
+
body;
|
|
16
|
+
constructor(provider, request, status, body) {
|
|
17
|
+
super(`${provider} ${request.method} ${request.path} failed: HTTP ${status}`);
|
|
18
|
+
this.name = "ProviderError";
|
|
19
|
+
this.provider = provider;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.method = request.method;
|
|
22
|
+
this.path = request.path;
|
|
23
|
+
this.body = body;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Builds the `request` function the clients in this directory are written against. */
|
|
27
|
+
export function createTransport(options) {
|
|
28
|
+
// Looked up per request, not captured: a test's mock server replaces `globalThis.fetch` after
|
|
29
|
+
// the clients have been constructed.
|
|
30
|
+
const doFetch = (input, init) => (options.fetch ?? globalThis.fetch)(input, init);
|
|
31
|
+
const base = options.baseUrl.replace(/\/+$/, "");
|
|
32
|
+
return async (request) => {
|
|
33
|
+
const url = new URL(base + request.path);
|
|
34
|
+
for (const [name, value] of Object.entries(request.query ?? {})) {
|
|
35
|
+
if (value !== undefined)
|
|
36
|
+
url.searchParams.set(name, String(value));
|
|
37
|
+
}
|
|
38
|
+
const headers = { accept: "application/json", ...options.headers };
|
|
39
|
+
if (request.body !== undefined)
|
|
40
|
+
headers["content-type"] = "application/json";
|
|
41
|
+
const response = await doFetch(url, {
|
|
42
|
+
method: request.method,
|
|
43
|
+
headers,
|
|
44
|
+
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
45
|
+
});
|
|
46
|
+
const text = await response.text();
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new ProviderError(options.provider, request, response.status, text);
|
|
49
|
+
}
|
|
50
|
+
return (text === "" ? undefined : JSON.parse(text));
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Percent-encodes one path segment, so an app name can never escape into the path. */
|
|
54
|
+
export function segment(value) {
|
|
55
|
+
return encodeURIComponent(value);
|
|
56
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type FetchLike } from "./http.js";
|
|
2
|
+
export interface LangfuseProject {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
export interface LangfuseProjects {
|
|
7
|
+
data: LangfuseProject[];
|
|
8
|
+
}
|
|
9
|
+
export interface LangfuseApiKey {
|
|
10
|
+
id: string;
|
|
11
|
+
publicKey: string;
|
|
12
|
+
/** Returned once, at creation; Langfuse never shows it again. */
|
|
13
|
+
secretKey: string;
|
|
14
|
+
note?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface LangfuseClientOptions {
|
|
17
|
+
/** `HF_LANGFUSE_URL` — the instance's origin. */
|
|
18
|
+
url: string;
|
|
19
|
+
/**
|
|
20
|
+
* `HF_LANGFUSE_ORG_KEY`, spelled `<publicKey>:<secretKey>`: Langfuse authenticates with HTTP
|
|
21
|
+
* Basic, and an organization-scoped key is a pair, so the pair is one config value rather
|
|
22
|
+
* than two that can be set out of step with each other.
|
|
23
|
+
*/
|
|
24
|
+
orgKey: string;
|
|
25
|
+
fetch?: FetchLike;
|
|
26
|
+
}
|
|
27
|
+
export declare class LangfuseClient {
|
|
28
|
+
private readonly request;
|
|
29
|
+
constructor(options: LangfuseClientOptions);
|
|
30
|
+
/** The org key's projects; a rerun finds the app's own by name instead of creating a second. */
|
|
31
|
+
listProjects(): Promise<LangfuseProjects>;
|
|
32
|
+
/** `retention` is required by the API: 0 keeps data indefinitely. */
|
|
33
|
+
createProject(body: {
|
|
34
|
+
name: string;
|
|
35
|
+
retention: number;
|
|
36
|
+
metadata?: Record<string, unknown>;
|
|
37
|
+
}): Promise<LangfuseProject>;
|
|
38
|
+
createApiKey(projectId: string, body?: {
|
|
39
|
+
note?: string;
|
|
40
|
+
}): Promise<LangfuseApiKey>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createTransport, segment } from "./http.js";
|
|
2
|
+
export class LangfuseClient {
|
|
3
|
+
request;
|
|
4
|
+
constructor(options) {
|
|
5
|
+
this.request = createTransport({
|
|
6
|
+
provider: "langfuse",
|
|
7
|
+
baseUrl: options.url.replace(/\/+$/, ""),
|
|
8
|
+
headers: {
|
|
9
|
+
authorization: `Basic ${Buffer.from(options.orgKey, "utf8").toString("base64")}`,
|
|
10
|
+
},
|
|
11
|
+
fetch: options.fetch,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/** The org key's projects; a rerun finds the app's own by name instead of creating a second. */
|
|
15
|
+
async listProjects() {
|
|
16
|
+
return await this.request({ method: "GET", path: "/api/public/projects" });
|
|
17
|
+
}
|
|
18
|
+
/** `retention` is required by the API: 0 keeps data indefinitely. */
|
|
19
|
+
async createProject(body) {
|
|
20
|
+
return await this.request({ method: "POST", path: "/api/public/projects", body });
|
|
21
|
+
}
|
|
22
|
+
async createApiKey(projectId, body = {}) {
|
|
23
|
+
return await this.request({
|
|
24
|
+
method: "POST",
|
|
25
|
+
path: `/api/public/projects/${segment(projectId)}/apiKeys`,
|
|
26
|
+
body,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type FetchLike } from "./http.js";
|
|
2
|
+
export declare const SENTRY_URL = "https://sentry.io";
|
|
3
|
+
export interface SentryProject {
|
|
4
|
+
id: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SentryProjectKey {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
dsn: {
|
|
12
|
+
public: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface SentryClientOptions {
|
|
16
|
+
/** `HF_SENTRY_TOKEN`. */
|
|
17
|
+
token: string;
|
|
18
|
+
url?: string;
|
|
19
|
+
fetch?: FetchLike;
|
|
20
|
+
}
|
|
21
|
+
export declare class SentryClient {
|
|
22
|
+
private readonly request;
|
|
23
|
+
constructor(options: SentryClientOptions);
|
|
24
|
+
createProject(org: string, body: {
|
|
25
|
+
name: string;
|
|
26
|
+
slug?: string;
|
|
27
|
+
platform?: string;
|
|
28
|
+
}): Promise<SentryProject>;
|
|
29
|
+
/** The DSN `hf new` writes into `SENTRY_DSN` is `keys[0].dsn.public`. */
|
|
30
|
+
listProjectKeys(org: string, project: string): Promise<SentryProjectKey[]>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createTransport, segment } from "./http.js";
|
|
2
|
+
export const SENTRY_URL = "https://sentry.io";
|
|
3
|
+
export class SentryClient {
|
|
4
|
+
request;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.request = createTransport({
|
|
7
|
+
provider: "sentry",
|
|
8
|
+
baseUrl: options.url ?? SENTRY_URL,
|
|
9
|
+
headers: { authorization: `Bearer ${options.token}` },
|
|
10
|
+
fetch: options.fetch,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
async createProject(org, body) {
|
|
14
|
+
return await this.request({
|
|
15
|
+
method: "POST",
|
|
16
|
+
path: `/api/0/organizations/${segment(org)}/projects/`,
|
|
17
|
+
body,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** The DSN `hf new` writes into `SENTRY_DSN` is `keys[0].dsn.public`. */
|
|
21
|
+
async listProjectKeys(org, project) {
|
|
22
|
+
return await this.request({
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: `/api/0/projects/${segment(org)}/${segment(project)}/keys/`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type RoleNames } from "@hyperfixation/db/migrator";
|
|
2
|
+
import { type Database } from "./database.js";
|
|
3
|
+
import type { AppStateStore } from "./state.js";
|
|
4
|
+
/** Created in the app's database before its first migration; both are `hf_*` table columns. */
|
|
5
|
+
export declare const REQUIRED_EXTENSIONS: readonly ["vector", "pg_trgm"];
|
|
6
|
+
export declare class ProvisionDatabaseError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
export interface ProvisionDatabaseOptions {
|
|
10
|
+
/** The name `hf new` was given; `hf_<app>` and the three roles derive from it. */
|
|
11
|
+
app: string;
|
|
12
|
+
state: AppStateStore;
|
|
13
|
+
/** Create the `_ro` role Metabase reads through. Default true. */
|
|
14
|
+
readonlyRole?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface ProvisionDatabaseResult {
|
|
17
|
+
databaseName: string;
|
|
18
|
+
roles: RoleNames;
|
|
19
|
+
createdDatabase: boolean;
|
|
20
|
+
/** Nothing was issued: the `database` step was already recorded. */
|
|
21
|
+
alreadyDone: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* A role that already existed was given a new password — a cold run against a live app.
|
|
24
|
+
*
|
|
25
|
+
* The deployed containers still hold the old one, so E3 has to order this
|
|
26
|
+
* rotate → Coolify env → redeploy; a caller that ignores this locks the app out of its own
|
|
27
|
+
* database until the next deploy.
|
|
28
|
+
*/
|
|
29
|
+
rotated: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The app's database, its extensions and its three roles, resumable and safe to rerun.
|
|
33
|
+
*
|
|
34
|
+
* Passwords live only in the state cache, and the order here is what keeps that honest: every
|
|
35
|
+
* password is written to the file **after** the cluster has accepted it, never before. A crash
|
|
36
|
+
* in between therefore leaves a state file that lags the database rather than one that leads
|
|
37
|
+
* it, and the next run — which still sees the step unrecorded, and still has no password to
|
|
38
|
+
* reuse — generates a fresh one and `ALTER`s again. Converging costs one more rotation; the
|
|
39
|
+
* other order would leave a file whose passwords nothing can log in with, and those files are
|
|
40
|
+
* the only copy there is.
|
|
41
|
+
*/
|
|
42
|
+
export declare function provisionDatabase(target: Database | string, options: ProvisionDatabaseOptions): Promise<ProvisionDatabaseResult>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { quoteIdent } from "@hyperfixation/db";
|
|
3
|
+
import { provisionRoles, roleNames } from "@hyperfixation/db/migrator";
|
|
4
|
+
import { openDatabaseUrl } from "./database.js";
|
|
5
|
+
import { deriveNames } from "./names.js";
|
|
6
|
+
/** Created in the app's database before its first migration; both are `hf_*` table columns. */
|
|
7
|
+
export const REQUIRED_EXTENSIONS = ["vector", "pg_trgm"];
|
|
8
|
+
export class ProvisionDatabaseError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "ProvisionDatabaseError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The app's database, its extensions and its three roles, resumable and safe to rerun.
|
|
16
|
+
*
|
|
17
|
+
* Passwords live only in the state cache, and the order here is what keeps that honest: every
|
|
18
|
+
* password is written to the file **after** the cluster has accepted it, never before. A crash
|
|
19
|
+
* in between therefore leaves a state file that lags the database rather than one that leads
|
|
20
|
+
* it, and the next run — which still sees the step unrecorded, and still has no password to
|
|
21
|
+
* reuse — generates a fresh one and `ALTER`s again. Converging costs one more rotation; the
|
|
22
|
+
* other order would leave a file whose passwords nothing can log in with, and those files are
|
|
23
|
+
* the only copy there is.
|
|
24
|
+
*/
|
|
25
|
+
export async function provisionDatabase(target, options) {
|
|
26
|
+
// `deriveNames` is `assertAppName`'s rule with hyphens allowed in the typed name only, so a
|
|
27
|
+
// name carrying a quote, a semicolon or a `$(` is refused here rather than quoted downstream.
|
|
28
|
+
const names = deriveNames(options.app);
|
|
29
|
+
const roles = roleNames(names.appName);
|
|
30
|
+
const { state } = options;
|
|
31
|
+
const stored = state.state.database ?? {};
|
|
32
|
+
if (state.isDone("database") &&
|
|
33
|
+
stored.migratorPassword !== undefined &&
|
|
34
|
+
stored.applicationPassword !== undefined) {
|
|
35
|
+
return {
|
|
36
|
+
databaseName: names.databaseName,
|
|
37
|
+
roles,
|
|
38
|
+
createdDatabase: false,
|
|
39
|
+
alreadyDone: true,
|
|
40
|
+
rotated: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const db = typeof target === "string" ? openDatabaseUrl(target) : target;
|
|
44
|
+
const adminUrl = db.adminUrl();
|
|
45
|
+
if (adminUrl === undefined) {
|
|
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.");
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const createdDatabase = await createDatabaseIfAbsent(db, names.databaseName);
|
|
52
|
+
for (const extension of REQUIRED_EXTENSIONS) {
|
|
53
|
+
await db.query(`CREATE EXTENSION IF NOT EXISTS ${quoteIdent(extension)}`, {
|
|
54
|
+
database: names.databaseName,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const wanted = options.readonlyRole !== false;
|
|
58
|
+
// A cold run is one with no password to reuse; it is a *rotation* only when the roles are
|
|
59
|
+
// already there, which is the case that strands a deployed app on its old credentials.
|
|
60
|
+
const regenerating = stored.migratorPassword === undefined || stored.applicationPassword === undefined;
|
|
61
|
+
const rotated = regenerating && (await anyRoleExists(db, [roles.migrator, roles.application]));
|
|
62
|
+
const provisioned = await provisionRoles(adminUrl, {
|
|
63
|
+
appName: names.appName,
|
|
64
|
+
databaseName: names.databaseName,
|
|
65
|
+
migratorPassword: stored.migratorPassword,
|
|
66
|
+
applicationPassword: stored.applicationPassword,
|
|
67
|
+
readonlyPassword: wanted ? (stored.readonlyPassword ?? generatePassword()) : undefined,
|
|
68
|
+
});
|
|
69
|
+
await state.patch({
|
|
70
|
+
database: {
|
|
71
|
+
migratorPassword: provisioned.migratorPassword,
|
|
72
|
+
applicationPassword: provisioned.applicationPassword,
|
|
73
|
+
...(provisioned.readonlyPassword === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { readonlyPassword: provisioned.readonlyPassword }),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
await state.markDone("database");
|
|
79
|
+
return { databaseName: names.databaseName, roles, createdDatabase, alreadyDone: false, rotated };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
if (typeof target === "string")
|
|
83
|
+
await db.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function createDatabaseIfAbsent(db, databaseName) {
|
|
87
|
+
const { rows } = await db.query(`SELECT 1 FROM pg_database WHERE datname = ${quoteLiteral(databaseName)}`);
|
|
88
|
+
if (rows.length > 0)
|
|
89
|
+
return false;
|
|
90
|
+
await db.query(`CREATE DATABASE ${quoteIdent(databaseName)}`);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
async function anyRoleExists(db, roles) {
|
|
94
|
+
const list = roles.map(quoteLiteral).join(", ");
|
|
95
|
+
const { rows } = await db.query(`SELECT 1 FROM pg_roles WHERE rolname IN (${list})`);
|
|
96
|
+
return rows.length > 0;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The `_ro` password. `provisionRoles` generates the other two itself, but creates the
|
|
100
|
+
* read-only role only when it is handed one.
|
|
101
|
+
*/
|
|
102
|
+
function generatePassword() {
|
|
103
|
+
return randomBytes(24).toString("base64url");
|
|
104
|
+
}
|
|
105
|
+
function quoteLiteral(value) {
|
|
106
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
107
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type BackupDump, type BackupSource } from "./backup-source.js";
|
|
2
|
+
import { type Database } from "./database.js";
|
|
3
|
+
import { type Runner } from "./runner.js";
|
|
4
|
+
import { type AppStateStore } from "./state.js";
|
|
5
|
+
/** Appended to `hf_<app>` for the database the dump is restored into and then dropped. */
|
|
6
|
+
export declare const SCRATCH_SUFFIX = "_restore_check";
|
|
7
|
+
/** Older than this and the dump gets a warning line; it never changes the exit code. */
|
|
8
|
+
export declare const STALE_DUMP_HOURS = 36;
|
|
9
|
+
export type RestoreVerdict = "ok" | "mismatch" | "live only" | "restored only";
|
|
10
|
+
export interface RestoreCheckRow {
|
|
11
|
+
table: string;
|
|
12
|
+
/** Absent when the table is not in the live database. */
|
|
13
|
+
live?: number;
|
|
14
|
+
/** Absent when the table is not in the restored dump. */
|
|
15
|
+
restored?: number;
|
|
16
|
+
verdict: RestoreVerdict;
|
|
17
|
+
}
|
|
18
|
+
export interface RestoreCheckResult {
|
|
19
|
+
databaseName: string;
|
|
20
|
+
/** Created and dropped by this call; never left behind. */
|
|
21
|
+
scratchDatabase: string;
|
|
22
|
+
dump: BackupDump;
|
|
23
|
+
dumpAgeHours: number;
|
|
24
|
+
/** The dump is older than `STALE_DUMP_HOURS`. Informational. */
|
|
25
|
+
dumpStale: boolean;
|
|
26
|
+
/** One row per table, `hf_*` or carrying `normalized_name`, sorted by name. */
|
|
27
|
+
rows: readonly RestoreCheckRow[];
|
|
28
|
+
/** Every row's verdict is `ok`. The command's exit code is `ok ? 0 : 1`. */
|
|
29
|
+
ok: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare class RestoreCheckError extends Error {
|
|
32
|
+
constructor(message: string);
|
|
33
|
+
}
|
|
34
|
+
export interface RestoreCheckOptions {
|
|
35
|
+
/** The name `hf new` was given; `hf_<app>` and the migrator role derive from it. */
|
|
36
|
+
app: string;
|
|
37
|
+
state: AppStateStore;
|
|
38
|
+
source: BackupSource;
|
|
39
|
+
/** Where `pg_restore` runs: the box, or this machine in a test. */
|
|
40
|
+
runner: Runner;
|
|
41
|
+
/** How the scratch database is created and both sides are counted. */
|
|
42
|
+
database: Database | string;
|
|
43
|
+
/**
|
|
44
|
+
* The cluster's admin URL **as the runner sees it**, for `pg_restore`.
|
|
45
|
+
*
|
|
46
|
+
* Not the same address as `database`: the laptop reaches the cluster through an `ssh -L`
|
|
47
|
+
* forward onto a local port, and a `pg_restore` running on the far side of that forward has to
|
|
48
|
+
* dial the box's own loopback. Defaults to `database`'s URL, which is what a test wants when
|
|
49
|
+
* both sides are the same machine.
|
|
50
|
+
*/
|
|
51
|
+
restoreAdminUrl?: string;
|
|
52
|
+
/** The `pg_restore` binary on the runner. */
|
|
53
|
+
pgRestorePath?: string;
|
|
54
|
+
now?: Date;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Restores the newest dump of `hf_<app>` beside the live database and compares row counts.
|
|
58
|
+
*
|
|
59
|
+
* The scratch database is dropped in a `finally`: it holds a full copy of the app's data, so
|
|
60
|
+
* leaving one behind on a failure would double the disk the app uses until someone noticed.
|
|
61
|
+
*
|
|
62
|
+
* `lastRestoreCheckAt` is written only by a run that counted both sides and found them equal.
|
|
63
|
+
* `hf doctor` warns on a stale timestamp, so a check that died halfway — or one that found a
|
|
64
|
+
* mismatch — has to leave the warning standing until a check actually passes.
|
|
65
|
+
*/
|
|
66
|
+
export declare function restoreCheck(options: RestoreCheckOptions): Promise<RestoreCheckResult>;
|
|
67
|
+
/** The argv `restoreCheck` runs — the array the test asserts against. */
|
|
68
|
+
export declare function pgRestoreArgv(options: {
|
|
69
|
+
url: string;
|
|
70
|
+
role: string;
|
|
71
|
+
file: string;
|
|
72
|
+
pgRestorePath?: string;
|
|
73
|
+
}): string[];
|
|
74
|
+
/** The table `hf restore-check` prints, and the two lines around it. */
|
|
75
|
+
export declare function formatRestoreCheck(result: RestoreCheckResult): string[];
|
|
76
|
+
export interface RestoreCheckAppOptions {
|
|
77
|
+
app: string;
|
|
78
|
+
/** Where the dumps are; defaults to Coolify's backup directory on the box. */
|
|
79
|
+
backupDir?: string;
|
|
80
|
+
/** Read the dump from Hetzner object storage instead. Not implemented; see `backup-source`. */
|
|
81
|
+
fromS3?: boolean;
|
|
82
|
+
env?: NodeJS.ProcessEnv;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `hf restore-check <name>`: the operator config, an `ssh` runner onto the box, and the check.
|
|
86
|
+
*
|
|
87
|
+
* The cluster admin password comes from libpq's own `PGPASSWORD` until E3 records Coolify's — the
|
|
88
|
+
* operator config has no key for it, and inventing one before the box has been looked at is
|
|
89
|
+
* exactly what risk 3 warns against.
|
|
90
|
+
*/
|
|
91
|
+
export declare function restoreCheckApp(options: RestoreCheckAppOptions): Promise<RestoreCheckResult>;
|