@dokebi/cli 0.4.26 → 0.4.28
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/AI.md +20 -3
- package/catalog.json +7 -7
- package/dist/bin.js +121 -3
- package/dist/check/shared-state-rules.d.ts +13 -0
- package/dist/check/shared-state-rules.js +5 -2
- package/dist/commands.js +76 -7
- package/dist/config-secrets.d.ts +54 -0
- package/dist/config-secrets.js +226 -0
- package/dist/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +564 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -0
- package/dist/project-inspector.js +17 -1
- package/dist/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -0
- package/package.json +3 -3
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// `dokebi config secrets` (D-0184): the secret option paths the app needs, by name only — where
|
|
2
|
+
// each one comes from (dokebi.yaml or the secrets file DOKEBI_SECRETS names) and which are
|
|
3
|
+
// missing. Values are never read into the result.
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { applySecrets, ConfigError, parseConfig, readConfigFile, SECRETS_VARIABLE, } from '@dokebi/config';
|
|
7
|
+
import { isPlaceholder } from './config-init.js';
|
|
8
|
+
import { configShow, readSettings } from './config-show.js';
|
|
9
|
+
import { debug } from './diagnostics.js';
|
|
10
|
+
import { CliError } from './errors.js';
|
|
11
|
+
import { parseObject } from './filesystem.js';
|
|
12
|
+
import { optionsKey } from './manifest.js';
|
|
13
|
+
import { dependencyMap } from './npm-client.js';
|
|
14
|
+
import { planLifecycle } from './plugin-lifecycle.js';
|
|
15
|
+
import { readState } from './project-state.js';
|
|
16
|
+
import { reloadSchemas } from './reloadable.js';
|
|
17
|
+
function valueAt(options, path) {
|
|
18
|
+
let value = options;
|
|
19
|
+
for (const key of path) {
|
|
20
|
+
if (Array.isArray(value) && /^\d+$/.test(key))
|
|
21
|
+
value = value[Number(key)];
|
|
22
|
+
else if (value && typeof value === 'object' && Object.hasOwn(value, key))
|
|
23
|
+
value = value[key];
|
|
24
|
+
else
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function empty(value) {
|
|
30
|
+
return (value === undefined ||
|
|
31
|
+
value === null ||
|
|
32
|
+
value === '' ||
|
|
33
|
+
isPlaceholder(value) ||
|
|
34
|
+
(Array.isArray(value) && (value.length === 0 || value.every(isPlaceholder))) ||
|
|
35
|
+
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0));
|
|
36
|
+
}
|
|
37
|
+
/** The JSON Schema types the option at `keys` may have (properties, records, anyOf/oneOf). */
|
|
38
|
+
export function schemaTypes(schema, keys) {
|
|
39
|
+
const types = new Set();
|
|
40
|
+
const walk = (node, rest, depth) => {
|
|
41
|
+
if (!node || typeof node !== 'object' || depth > 32)
|
|
42
|
+
return;
|
|
43
|
+
const value = node;
|
|
44
|
+
for (const branch of ['anyOf', 'oneOf', 'allOf'])
|
|
45
|
+
if (Array.isArray(value[branch]))
|
|
46
|
+
for (const item of value[branch])
|
|
47
|
+
walk(item, rest, depth + 1);
|
|
48
|
+
if (!rest.length) {
|
|
49
|
+
if (typeof value.type === 'string')
|
|
50
|
+
types.add(value.type);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const [key, ...tail] = rest;
|
|
54
|
+
const properties = value.properties;
|
|
55
|
+
if (properties && Object.hasOwn(properties, key))
|
|
56
|
+
walk(properties[key], tail, depth + 1);
|
|
57
|
+
else if (value.additionalProperties && typeof value.additionalProperties === 'object')
|
|
58
|
+
walk(value.additionalProperties, tail, depth + 1);
|
|
59
|
+
else if (/^\d+$/.test(key) && value.items)
|
|
60
|
+
walk(value.items, tail, depth + 1);
|
|
61
|
+
};
|
|
62
|
+
walk(schema, keys, 0);
|
|
63
|
+
return types;
|
|
64
|
+
}
|
|
65
|
+
/** The type of the option at `path` as `SecretEntry.type` says it. */
|
|
66
|
+
function typeOf(schemas, path) {
|
|
67
|
+
const [section = '', ...rest] = path.split('.');
|
|
68
|
+
const types = [...schemaTypes(schemas.get(section), rest)].sort();
|
|
69
|
+
return types.length === 0 ? null : types.length === 1 ? types[0] : types;
|
|
70
|
+
}
|
|
71
|
+
/** The same failure as the app's start, as a CLI error with the paths and no values. */
|
|
72
|
+
function configFailure(error) {
|
|
73
|
+
return new CliError(error.code, error.message, {
|
|
74
|
+
phase: 'arguments',
|
|
75
|
+
details: {
|
|
76
|
+
issues: error.issues.map((issue) => ({
|
|
77
|
+
path: issue.instancePath
|
|
78
|
+
.split('/')
|
|
79
|
+
.slice(1)
|
|
80
|
+
.map((key) => key.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
81
|
+
.join('.'),
|
|
82
|
+
keyword: issue.keyword,
|
|
83
|
+
})),
|
|
84
|
+
fix: error.fix,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The secrets the installed plugins generate at install (their plans' `configSecrets`, such as
|
|
90
|
+
* `dokebi-session.secrets`): each plugin needs one to start.
|
|
91
|
+
*/
|
|
92
|
+
export async function requiredSecretPaths(root, options) {
|
|
93
|
+
const state = readState(parseObject(await readFile(join(root, '.dokebi/state.json'), 'utf8'), 'state'));
|
|
94
|
+
const projectPackages = Object.keys(dependencyMap(parseObject(await readFile(join(root, 'package.json'), 'utf8'), 'package.json')));
|
|
95
|
+
const paths = [];
|
|
96
|
+
// A plugin whose install module cannot run here (not installed: npm install) is named, so the
|
|
97
|
+
// list is never silently short.
|
|
98
|
+
const unreadable = [];
|
|
99
|
+
for (const plugin of state.plugins) {
|
|
100
|
+
let plan;
|
|
101
|
+
try {
|
|
102
|
+
plan = await planLifecycle({ base: root, plugin, options, plugins: state.plugins, projectPackages }, 'planInstall');
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (!(error instanceof CliError) || error.code !== 'PLUGIN_INSTALL_FAILED')
|
|
106
|
+
throw error;
|
|
107
|
+
unreadable.push(plugin.id);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const path of plan.configSecrets)
|
|
111
|
+
paths.push([optionsKey(plugin), ...path].join('.'));
|
|
112
|
+
}
|
|
113
|
+
return { paths, unreadable };
|
|
114
|
+
}
|
|
115
|
+
/** The warning for plugins whose generated secrets could not be read (their install modules). */
|
|
116
|
+
export function unreadableWarning(plugins) {
|
|
117
|
+
return plugins.length
|
|
118
|
+
? [
|
|
119
|
+
{
|
|
120
|
+
code: 'SECRETS_PLAN_UNREADABLE',
|
|
121
|
+
path: plugins.join(', '),
|
|
122
|
+
hint: 'the install modules of these plugins could not run (are their packages installed? npm install), so the secrets they generate are not in this list',
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
: [];
|
|
126
|
+
}
|
|
127
|
+
/** `dokebi config secrets`: secret paths by name, where each is set, and the missing ones. */
|
|
128
|
+
export async function configSecrets(root, env = process.env) {
|
|
129
|
+
const { target, source } = await readSettings(root);
|
|
130
|
+
let document;
|
|
131
|
+
try {
|
|
132
|
+
document = parseConfig(source);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error instanceof ConfigError)
|
|
136
|
+
throw configFailure(error);
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
const secretsFile = env[SECRETS_VARIABLE] || undefined;
|
|
140
|
+
// The merged options (conflicts and an unsafe file refuse as at start); the list is checked
|
|
141
|
+
// below, so a missing listed secret is reported instead of thrown.
|
|
142
|
+
let merged = document.options;
|
|
143
|
+
let secretsText;
|
|
144
|
+
let secretOptions;
|
|
145
|
+
if (secretsFile !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
merged = (await applySecrets({ schemaVersion: 1, options: document.options }, secretsFile))
|
|
148
|
+
.options;
|
|
149
|
+
secretsText = await readConfigFile(secretsFile);
|
|
150
|
+
secretOptions = parseConfig(secretsText).options;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof ConfigError)
|
|
154
|
+
throw configFailure(error);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const fromConfig = new Set((await configShow(root, undefined, { source, file: target.file })).secrets);
|
|
159
|
+
const fromSecrets = new Set(secretsText === undefined
|
|
160
|
+
? []
|
|
161
|
+
: (await configShow(root, undefined, { source: secretsText, file: secretsFile }))
|
|
162
|
+
.secrets);
|
|
163
|
+
const planned = await requiredSecretPaths(root, document.options);
|
|
164
|
+
const state = readState(parseObject(await readFile(join(root, '.dokebi/state.json'), 'utf8'), 'state'));
|
|
165
|
+
// The types tell a tool that writes the secrets file a list from a text (Studio v2, 11-01).
|
|
166
|
+
const schemas = await reloadSchemas(root, state.plugins, state.settings ?? []);
|
|
167
|
+
const required = new Set(planned.paths);
|
|
168
|
+
const listed = document.secrets ?? [];
|
|
169
|
+
const paths = [...new Set([...fromConfig, ...fromSecrets, ...listed, ...required])].sort();
|
|
170
|
+
const secrets = paths.map((path) => {
|
|
171
|
+
const set = !empty(valueAt(merged, path.split('.')));
|
|
172
|
+
return {
|
|
173
|
+
path,
|
|
174
|
+
source: !set ? null : !empty(valueAt(secretOptions, path.split('.'))) ? 'secrets' : 'config',
|
|
175
|
+
listed: listed.includes(path),
|
|
176
|
+
required: required.has(path),
|
|
177
|
+
type: typeOf(schemas, path),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const missing = secrets.filter((entry) => entry.source === null).map((entry) => entry.path);
|
|
181
|
+
const warnings = [
|
|
182
|
+
...unreadableWarning(planned.unreadable),
|
|
183
|
+
...(secretsFile === undefined
|
|
184
|
+
? []
|
|
185
|
+
: secrets
|
|
186
|
+
.filter((entry) => entry.source === 'config' && fromConfig.has(entry.path))
|
|
187
|
+
.map((entry) => ({
|
|
188
|
+
code: 'SECRET_IN_CONFIG_FILE',
|
|
189
|
+
path: entry.path,
|
|
190
|
+
hint: `${target.file} holds this secret although ${SECRETS_VARIABLE} names a secrets file: move it there (the configuration file is the one deploy/ keeps without secrets)`,
|
|
191
|
+
}))),
|
|
192
|
+
];
|
|
193
|
+
debug('Configuration secrets listed', {
|
|
194
|
+
secrets: secrets.length,
|
|
195
|
+
missing: missing.length,
|
|
196
|
+
listed: listed.length,
|
|
197
|
+
secretsFile: secretsFile !== undefined,
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
file: target.file,
|
|
201
|
+
secretsFile: secretsFile ?? null,
|
|
202
|
+
listed,
|
|
203
|
+
secrets,
|
|
204
|
+
missing,
|
|
205
|
+
...(warnings.length ? { warnings } : {}),
|
|
206
|
+
...(missing.length
|
|
207
|
+
? {
|
|
208
|
+
nextSteps: [
|
|
209
|
+
{
|
|
210
|
+
command: secretsFile === undefined
|
|
211
|
+
? `${SECRETS_VARIABLE}=<the 0600 secrets file> npx dokebi config secrets --json`
|
|
212
|
+
: `edit ${secretsFile}`,
|
|
213
|
+
why: `set ${missing.join(', ')} (the app refuses to start without the listed ones: CONFIG_SECRETS_MISSING)`,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
: {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/** The result for people: one line per path. */
|
|
221
|
+
export function configSecretsText(result) {
|
|
222
|
+
const lines = result.secrets.map((entry) => `${entry.source === null ? 'MISSING' : entry.source.padEnd(7)} ${entry.path}${entry.type === 'array' ? ' (a list)' : ''}${entry.listed ? ' (listed)' : ''}${entry.required ? ' (required)' : ''}`);
|
|
223
|
+
return `${lines.join('\n') || 'no secret options'}\n${result.missing.length ? `missing: ${result.missing.join(', ')}` : 'no secret missing'}${(result.warnings ?? [])
|
|
224
|
+
.map((warning) => `\nwarning: ${warning.code} (${warning.path}): ${warning.hint}`)
|
|
225
|
+
.join('')}\n`;
|
|
226
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** deploy/backup.mjs */
|
|
2
|
+
export declare const BACKUP_SCRIPT = "// Backs up the app's databases (D-0184): every dokebi-db connection whose URL\n// (connections.<name>.options.url) is postgres or mongodb, read from the settings the app reads (DOKEBI_CONFIG and the secrets file that\n// DOKEBI_SECRETS names). Each backup is <DOKEBI_BACKUP_DIR>/<connection>/<UTC time>.<pgdump|archive.gz>\n// (mode 0600); older ones are removed except the newest per day, per week and per month\n// (DOKEBI_BACKUP_KEEP=days,weeks,months, default 7,4,6). One JSON line on stdout; exit 1 when a\n// connection failed. pg_dump (PostgreSQL client tools of the server's major version or later)\n// and mongodump (MongoDB Database Tools) must be on PATH.\n//\n// DOKEBI_CONFIG=/etc/<app>/<instance>/dokebi.yaml DOKEBI_SECRETS=/etc/<app>/<instance>/secrets.yaml \\\n// DOKEBI_BACKUP_DIR=/var/backups/<app>/<instance> node deploy/backup.mjs\n//\n// DOKEBI_BACKUP_CONNECTIONS=main,reports limits it to those connections.\nimport { spawn } from 'node:child_process';\nimport { openSync, closeSync, fsyncSync } from 'node:fs';\nimport { mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { loadConfig } from '@dokebi/core';\n\nprocess.umask(0o077);\nconst started = Date.now();\nconst NAME = /^(\\d{8}T\\d{6}Z)\\.(pgdump|archive\\.gz)$/;\n\nfunction fail(message) {\n process.stdout.write(`${JSON.stringify({ ok: false, error: message })}\\n`);\n process.exit(1);\n}\n\nconst configFile = process.env.DOKEBI_CONFIG;\nconst folder = process.env.DOKEBI_BACKUP_DIR;\nif (!configFile) fail('DOKEBI_CONFIG is not set: the settings file of the instance');\nif (!folder) fail('DOKEBI_BACKUP_DIR is not set: the folder that keeps the backups');\nconst keep = (process.env.DOKEBI_BACKUP_KEEP ?? '7,4,6').split(',').map(Number);\nif (keep.length !== 3 || keep.some((value) => !Number.isSafeInteger(value) || value < 0))\n fail('DOKEBI_BACKUP_KEEP is days,weeks,months (whole numbers), e.g. 7,4,6');\nconst only = process.env.DOKEBI_BACKUP_CONNECTIONS?.split(',').filter(Boolean);\n\n/** postgres or mongodb by the URL scheme; anything else is not backed up here. */\nfunction kindOf(url) {\n const scheme = url.slice(0, url.indexOf(':')).toLowerCase();\n if (scheme === 'postgres' || scheme === 'postgresql') return 'postgres';\n if (scheme === 'mongodb' || scheme === 'mongodb+srv') return 'mongodb';\n return undefined;\n}\n\n/** Runs a tool with stdout into `file`; stderr is kept for the error (it never holds the password). */\nfunction run(command, args, env, file) {\n return new Promise((resolve, reject) => {\n const fd = openSync(file, 'wx', 0o600);\n let stderr = '';\n const child = spawn(command, args, { env, stdio: ['ignore', fd, 'pipe'] });\n child.stderr.setEncoding('utf8');\n child.stderr.on('data', (chunk) => {\n stderr = (stderr + chunk).slice(-4000);\n });\n child.once('error', (error) => {\n closeSync(fd);\n reject(new Error(`${command} could not run: ${error.message}`));\n });\n child.once('close', (code, signal) => {\n try {\n fsyncSync(fd);\n } finally {\n closeSync(fd);\n }\n if (code === 0) resolve();\n else reject(new Error(`${command} exited with ${code ?? signal}: ${stderr.trim()}`));\n });\n });\n}\n\nasync function backup(name, url, target) {\n const kind = kindOf(url);\n const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\\.\\d+Z$/, 'Z');\n const directory = join(folder, name);\n await mkdir(directory, { recursive: true, mode: 0o700 });\n const file = join(directory, `${stamp}.${kind === 'postgres' ? 'pgdump' : 'archive.gz'}`);\n const partial = `${file}.partial`;\n const begun = Date.now();\n try {\n if (kind === 'postgres') {\n const parsed = new URL(url);\n const password = decodeURIComponent(parsed.password);\n parsed.password = '';\n await run(\n 'pg_dump',\n ['--format=custom', '--no-owner', '--no-privileges', `--dbname=${parsed.href}`],\n { ...process.env, PGPASSWORD: password },\n partial,\n );\n } else {\n // The URI (with its password) goes in a 0600 file, never on the command line.\n const options = join(directory, `.${stamp}.mongodump.yaml`);\n await writeFile(options, `uri: ${JSON.stringify(url)}\\n`, { mode: 0o600, flag: 'wx' });\n try {\n await run('mongodump', [`--config=${options}`, '--archive', '--gzip'], process.env, partial);\n } finally {\n await rm(options, { force: true });\n }\n }\n await rename(partial, file);\n } catch (error) {\n await rm(partial, { force: true });\n throw error;\n }\n const { size } = await stat(file);\n target.push({ connection: name, kind, file, bytes: size, ms: Date.now() - begun });\n}\n\n/** The newest backup per day, per ISO week and per month, as many of each as DOKEBI_BACKUP_KEEP says. */\nasync function prune(name) {\n const directory = join(folder, name);\n const files = (await readdir(directory)).filter((entry) => NAME.test(entry)).sort().reverse();\n const kept = new Set();\n const pick = (count, period) => {\n const seen = new Set();\n for (const file of files) {\n if (seen.size >= count) break;\n const key = period(file.slice(0, 8));\n if (seen.has(key)) continue;\n seen.add(key);\n kept.add(file);\n }\n };\n const week = (day) => {\n const date = new Date(Date.UTC(+day.slice(0, 4), +day.slice(4, 6) - 1, +day.slice(6, 8)));\n const thursday = new Date(date);\n thursday.setUTCDate(date.getUTCDate() + 3 - ((date.getUTCDay() + 6) % 7));\n const first = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));\n return `${thursday.getUTCFullYear()}-${Math.round((thursday - first) / 604800000 + 1)}`;\n };\n pick(keep[0], (day) => day);\n pick(keep[1], week);\n pick(keep[2], (day) => day.slice(0, 6));\n const removed = files.filter((file) => !kept.has(file));\n for (const file of removed) await rm(join(directory, file));\n // A partial file of a run that died is removed after a day.\n for (const entry of await readdir(directory))\n if (entry.endsWith('.partial')) {\n const { mtimeMs } = await stat(join(directory, entry));\n if (Date.now() - mtimeMs > 86_400_000) await rm(join(directory, entry), { force: true });\n }\n return removed.length;\n}\n\nlet document;\ntry {\n document = await loadConfig(configFile);\n} catch (error) {\n fail(`settings could not be read: ${error.code ?? ''} ${error.message}`);\n}\nconst connections = document.options['dokebi-db']?.connections ?? {};\nconst done = [];\nconst failed = [];\nconst skipped = [];\nlet removed = 0;\nfor (const [name, connection] of Object.entries(connections)) {\n if (only && !only.includes(name)) continue;\n // options.dokebi-db.connections.<name> = { adapter, options: { url, \u2026 } }\n const url = typeof connection?.options?.url === 'string' ? connection.options.url : undefined;\n if (!url || !kindOf(url)) {\n skipped.push({ connection: name, reason: url ? 'not postgres or mongodb' : 'no url' });\n continue;\n }\n try {\n await backup(name, url, done);\n removed += await prune(name);\n } catch (error) {\n failed.push({ connection: name, error: error.message });\n }\n}\nconst ok = failed.length === 0 && done.length > 0;\nprocess.stdout.write(\n `${JSON.stringify({ ok, backups: done, failed, skipped, removed, ms: Date.now() - started })}\\n`,\n);\nif (!ok) process.exitCode = 1;\n";
|
|
3
|
+
/** deploy/restore-drill.mjs */
|
|
4
|
+
export declare const RESTORE_DRILL_SCRIPT = "// Restore drill (D-0184): proves the newest backup of a connection can be restored. It restores\n// it into DOKEBI_RESTORE_URL (an empty scratch database, never the live one: refused when it is\n// the same database) and compares the restored database with the live one: tables or\n// collections, and the rows of the migration history (dokebi_migrations). One JSON line; exit 1\n// when the restore failed or the restored database is empty. Run it every month and after a\n// change of the PostgreSQL or MongoDB version.\n//\n// DOKEBI_CONFIG=/etc/<app>/<instance>/dokebi.yaml DOKEBI_SECRETS=/etc/<app>/<instance>/secrets.yaml \\\n// DOKEBI_BACKUP_DIR=/var/backups/<app>/<instance> DOKEBI_RESTORE_URL=postgres://...<scratch db> \\\n// node deploy/restore-drill.mjs\n//\n// DOKEBI_RESTORE_CONNECTION picks the connection (default main). DOKEBI_RESTORE_URL is a secret:\n// put it in the environment of this run only (a systemd-run --property=EnvironmentFile=...).\nimport { spawn } from 'node:child_process';\nimport { createReadStream } from 'node:fs';\nimport { readdir, rm, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { loadConfig } from '@dokebi/core';\n\nprocess.umask(0o077);\nconst started = Date.now();\nfunction fail(message, extra = {}) {\n process.stdout.write(`${JSON.stringify({ ok: false, error: message, ...extra })}\\n`);\n process.exit(1);\n}\nconst configFile = process.env.DOKEBI_CONFIG;\nconst folder = process.env.DOKEBI_BACKUP_DIR;\nconst target = process.env.DOKEBI_RESTORE_URL;\nconst name = process.env.DOKEBI_RESTORE_CONNECTION || 'main';\nif (!configFile || !folder || !target)\n fail('DOKEBI_CONFIG, DOKEBI_BACKUP_DIR and DOKEBI_RESTORE_URL must be set');\n\nlet document;\ntry {\n document = await loadConfig(configFile);\n} catch (error) {\n fail(`settings could not be read: ${error.code ?? ''} ${error.message}`);\n}\nconst live = document.options['dokebi-db']?.connections?.[name]?.options?.url;\nif (typeof live !== 'string') fail(`no url for the dokebi-db connection ${name}`);\nconst scheme = (url) => url.slice(0, url.indexOf(':')).toLowerCase();\nconst kind = ['postgres', 'postgresql'].includes(scheme(live))\n ? 'postgres'\n : ['mongodb', 'mongodb+srv'].includes(scheme(live))\n ? 'mongodb'\n : fail(`the connection ${name} is not postgres or mongodb`);\nif (kind !== (['postgres', 'postgresql'].includes(scheme(target)) ? 'postgres' : 'mongodb'))\n fail('DOKEBI_RESTORE_URL is not the same kind of database as the connection');\nconst where = (url) => {\n const parsed = new URL(url);\n return `${parsed.hostname}:${parsed.port}/${parsed.pathname.slice(1)}`;\n};\nif (where(live) === where(target)) fail('DOKEBI_RESTORE_URL names the live database; use a scratch one');\n\nconst extension = kind === 'postgres' ? '.pgdump' : '.archive.gz';\nlet entries = [];\ntry {\n entries = await readdir(join(folder, name));\n} catch (error) {\n if (error.code !== 'ENOENT') throw error;\n}\nconst files = entries\n .filter((file) => /^\\d{8}T\\d{6}Z/.test(file) && file.endsWith(extension))\n .sort();\nconst newest = files.at(-1);\nif (!newest) fail(`no backup of ${name} in ${join(folder, name)}`);\nconst backup = join(folder, name, newest);\n\nfunction run(command, args, env, input) {\n return new Promise((resolve, reject) => {\n let stderr = '';\n const child = spawn(command, args, { env, stdio: ['pipe', 'ignore', 'pipe'] });\n child.stderr.setEncoding('utf8');\n child.stderr.on('data', (chunk) => {\n stderr = (stderr + chunk).slice(-4000);\n });\n child.once('error', (error) => reject(new Error(`${command} could not run: ${error.message}`)));\n // A tool that stops reading (it failed) breaks the pipe: its exit code says why.\n child.stdin.on('error', (error) => {\n stderr = `${stderr}\\n(input: ${error.code ?? error.message})`.slice(-4000);\n });\n const source = createReadStream(input);\n source.once('error', (error) => {\n child.kill();\n reject(new Error(`the backup could not be read: ${error.message}`));\n });\n child.once('close', (code, signal) =>\n code === 0\n ? resolve()\n : reject(new Error(`${command} exited with ${code ?? signal}: ${stderr.trim()}`)),\n );\n source.pipe(child.stdin);\n });\n}\n\nasync function postgresCounts(url) {\n const { default: pg } = await import('pg');\n const client = new pg.Client({ connectionString: url });\n await client.connect();\n try {\n const tables = await client.query(\n \"select count(*)::int as n from information_schema.tables where table_type = 'BASE TABLE' and table_schema not in ('pg_catalog', 'information_schema')\",\n );\n const history = await client.query(\"select to_regclass('dokebi_migrations') is not null as present\");\n const migrations = history.rows[0].present\n ? (await client.query('select count(*)::int as n from dokebi_migrations')).rows[0].n\n : null;\n return { tables: tables.rows[0].n, migrations };\n } finally {\n await client.end();\n }\n}\n\nasync function mongoCounts(url) {\n const { MongoClient } = await import('mongodb');\n const client = new MongoClient(url);\n await client.connect();\n try {\n const db = client.db();\n const collections = (await db.listCollections({}, { nameOnly: true }).toArray()).filter(\n (item) => !item.name.startsWith('system.'),\n );\n const migrations = collections.some((item) => item.name === 'dokebi_migrations')\n ? await db.collection('dokebi_migrations').countDocuments()\n : null;\n return { tables: collections.length, migrations };\n } finally {\n await client.close();\n }\n}\n\ntry {\n if (kind === 'postgres') {\n const parsed = new URL(target);\n const password = decodeURIComponent(parsed.password);\n parsed.password = '';\n await run(\n 'pg_restore',\n ['--clean', '--if-exists', '--no-owner', '--no-privileges', `--dbname=${parsed.href}`],\n { ...process.env, PGPASSWORD: password },\n backup,\n );\n } else {\n const from = new URL(live).pathname.slice(1);\n const to = new URL(target).pathname.slice(1);\n // A database in the URI would act as a --db filter (nothing of the backup matches it): the\n // URI names the server only, --nsFrom/--nsTo the databases.\n const server = new URL(target);\n server.pathname = '/';\n const options = join(folder, name, '.restore-drill.mongorestore.yaml');\n await writeFile(options, `uri: ${JSON.stringify(server.href)}\\n`, { mode: 0o600 });\n try {\n await run(\n 'mongorestore',\n [`--config=${options}`, '--archive', '--gzip', '--drop', `--nsFrom=${from}.*`, `--nsTo=${to}.*`],\n process.env,\n backup,\n );\n } finally {\n await rm(options, { force: true });\n }\n }\n} catch (error) {\n fail(error.message, { backup });\n}\nconst count = kind === 'postgres' ? postgresCounts : mongoCounts;\nconst [restored, current] = await Promise.all([count(target), count(live)]);\nconst ok = restored.tables > 0;\nconst notes = [];\nif (restored.tables !== current.tables)\n notes.push(`the live database has ${current.tables} tables or collections, the backup ${restored.tables}`);\nif (restored.migrations !== current.migrations)\n notes.push('the live migration history differs from the backup (a migration ran since)');\nprocess.stdout.write(\n `${JSON.stringify({ ok, connection: name, kind, backup, restored, live: current, notes, ms: Date.now() - started })}\\n`,\n);\nif (!ok) process.exitCode = 1;\n";
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// The backup and restore-drill scripts `dokebi deploy init` writes into deploy/ (D-0184 §6).
|
|
2
|
+
// They are the project's files: plain ESM run with `node` in a release folder, using the app's
|
|
3
|
+
// own @dokebi/core (the settings with the secrets file) and its database drivers (pg, mongodb).
|
|
4
|
+
// Secrets never reach a command line: pg_dump and pg_restore get the password in PGPASSWORD,
|
|
5
|
+
// mongodump and mongorestore read the URI from a 0600 file passed with --config.
|
|
6
|
+
/** deploy/backup.mjs */
|
|
7
|
+
export const BACKUP_SCRIPT = `// Backs up the app's databases (D-0184): every dokebi-db connection whose URL
|
|
8
|
+
// (connections.<name>.options.url) is postgres or mongodb, read from the settings the app reads (DOKEBI_CONFIG and the secrets file that
|
|
9
|
+
// DOKEBI_SECRETS names). Each backup is <DOKEBI_BACKUP_DIR>/<connection>/<UTC time>.<pgdump|archive.gz>
|
|
10
|
+
// (mode 0600); older ones are removed except the newest per day, per week and per month
|
|
11
|
+
// (DOKEBI_BACKUP_KEEP=days,weeks,months, default 7,4,6). One JSON line on stdout; exit 1 when a
|
|
12
|
+
// connection failed. pg_dump (PostgreSQL client tools of the server's major version or later)
|
|
13
|
+
// and mongodump (MongoDB Database Tools) must be on PATH.
|
|
14
|
+
//
|
|
15
|
+
// DOKEBI_CONFIG=/etc/<app>/<instance>/dokebi.yaml DOKEBI_SECRETS=/etc/<app>/<instance>/secrets.yaml \\
|
|
16
|
+
// DOKEBI_BACKUP_DIR=/var/backups/<app>/<instance> node deploy/backup.mjs
|
|
17
|
+
//
|
|
18
|
+
// DOKEBI_BACKUP_CONNECTIONS=main,reports limits it to those connections.
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { openSync, closeSync, fsyncSync } from 'node:fs';
|
|
21
|
+
import { mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { loadConfig } from '@dokebi/core';
|
|
24
|
+
|
|
25
|
+
process.umask(0o077);
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
const NAME = /^(\\d{8}T\\d{6}Z)\\.(pgdump|archive\\.gz)$/;
|
|
28
|
+
|
|
29
|
+
function fail(message) {
|
|
30
|
+
process.stdout.write(\`\${JSON.stringify({ ok: false, error: message })}\\n\`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const configFile = process.env.DOKEBI_CONFIG;
|
|
35
|
+
const folder = process.env.DOKEBI_BACKUP_DIR;
|
|
36
|
+
if (!configFile) fail('DOKEBI_CONFIG is not set: the settings file of the instance');
|
|
37
|
+
if (!folder) fail('DOKEBI_BACKUP_DIR is not set: the folder that keeps the backups');
|
|
38
|
+
const keep = (process.env.DOKEBI_BACKUP_KEEP ?? '7,4,6').split(',').map(Number);
|
|
39
|
+
if (keep.length !== 3 || keep.some((value) => !Number.isSafeInteger(value) || value < 0))
|
|
40
|
+
fail('DOKEBI_BACKUP_KEEP is days,weeks,months (whole numbers), e.g. 7,4,6');
|
|
41
|
+
const only = process.env.DOKEBI_BACKUP_CONNECTIONS?.split(',').filter(Boolean);
|
|
42
|
+
|
|
43
|
+
/** postgres or mongodb by the URL scheme; anything else is not backed up here. */
|
|
44
|
+
function kindOf(url) {
|
|
45
|
+
const scheme = url.slice(0, url.indexOf(':')).toLowerCase();
|
|
46
|
+
if (scheme === 'postgres' || scheme === 'postgresql') return 'postgres';
|
|
47
|
+
if (scheme === 'mongodb' || scheme === 'mongodb+srv') return 'mongodb';
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Runs a tool with stdout into \`file\`; stderr is kept for the error (it never holds the password). */
|
|
52
|
+
function run(command, args, env, file) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const fd = openSync(file, 'wx', 0o600);
|
|
55
|
+
let stderr = '';
|
|
56
|
+
const child = spawn(command, args, { env, stdio: ['ignore', fd, 'pipe'] });
|
|
57
|
+
child.stderr.setEncoding('utf8');
|
|
58
|
+
child.stderr.on('data', (chunk) => {
|
|
59
|
+
stderr = (stderr + chunk).slice(-4000);
|
|
60
|
+
});
|
|
61
|
+
child.once('error', (error) => {
|
|
62
|
+
closeSync(fd);
|
|
63
|
+
reject(new Error(\`\${command} could not run: \${error.message}\`));
|
|
64
|
+
});
|
|
65
|
+
child.once('close', (code, signal) => {
|
|
66
|
+
try {
|
|
67
|
+
fsyncSync(fd);
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
if (code === 0) resolve();
|
|
72
|
+
else reject(new Error(\`\${command} exited with \${code ?? signal}: \${stderr.trim()}\`));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function backup(name, url, target) {
|
|
78
|
+
const kind = kindOf(url);
|
|
79
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\\.\\d+Z$/, 'Z');
|
|
80
|
+
const directory = join(folder, name);
|
|
81
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
82
|
+
const file = join(directory, \`\${stamp}.\${kind === 'postgres' ? 'pgdump' : 'archive.gz'}\`);
|
|
83
|
+
const partial = \`\${file}.partial\`;
|
|
84
|
+
const begun = Date.now();
|
|
85
|
+
try {
|
|
86
|
+
if (kind === 'postgres') {
|
|
87
|
+
const parsed = new URL(url);
|
|
88
|
+
const password = decodeURIComponent(parsed.password);
|
|
89
|
+
parsed.password = '';
|
|
90
|
+
await run(
|
|
91
|
+
'pg_dump',
|
|
92
|
+
['--format=custom', '--no-owner', '--no-privileges', \`--dbname=\${parsed.href}\`],
|
|
93
|
+
{ ...process.env, PGPASSWORD: password },
|
|
94
|
+
partial,
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
// The URI (with its password) goes in a 0600 file, never on the command line.
|
|
98
|
+
const options = join(directory, \`.\${stamp}.mongodump.yaml\`);
|
|
99
|
+
await writeFile(options, \`uri: \${JSON.stringify(url)}\\n\`, { mode: 0o600, flag: 'wx' });
|
|
100
|
+
try {
|
|
101
|
+
await run('mongodump', [\`--config=\${options}\`, '--archive', '--gzip'], process.env, partial);
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(options, { force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await rename(partial, file);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
await rm(partial, { force: true });
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
const { size } = await stat(file);
|
|
112
|
+
target.push({ connection: name, kind, file, bytes: size, ms: Date.now() - begun });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The newest backup per day, per ISO week and per month, as many of each as DOKEBI_BACKUP_KEEP says. */
|
|
116
|
+
async function prune(name) {
|
|
117
|
+
const directory = join(folder, name);
|
|
118
|
+
const files = (await readdir(directory)).filter((entry) => NAME.test(entry)).sort().reverse();
|
|
119
|
+
const kept = new Set();
|
|
120
|
+
const pick = (count, period) => {
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
if (seen.size >= count) break;
|
|
124
|
+
const key = period(file.slice(0, 8));
|
|
125
|
+
if (seen.has(key)) continue;
|
|
126
|
+
seen.add(key);
|
|
127
|
+
kept.add(file);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const week = (day) => {
|
|
131
|
+
const date = new Date(Date.UTC(+day.slice(0, 4), +day.slice(4, 6) - 1, +day.slice(6, 8)));
|
|
132
|
+
const thursday = new Date(date);
|
|
133
|
+
thursday.setUTCDate(date.getUTCDate() + 3 - ((date.getUTCDay() + 6) % 7));
|
|
134
|
+
const first = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));
|
|
135
|
+
return \`\${thursday.getUTCFullYear()}-\${Math.round((thursday - first) / 604800000 + 1)}\`;
|
|
136
|
+
};
|
|
137
|
+
pick(keep[0], (day) => day);
|
|
138
|
+
pick(keep[1], week);
|
|
139
|
+
pick(keep[2], (day) => day.slice(0, 6));
|
|
140
|
+
const removed = files.filter((file) => !kept.has(file));
|
|
141
|
+
for (const file of removed) await rm(join(directory, file));
|
|
142
|
+
// A partial file of a run that died is removed after a day.
|
|
143
|
+
for (const entry of await readdir(directory))
|
|
144
|
+
if (entry.endsWith('.partial')) {
|
|
145
|
+
const { mtimeMs } = await stat(join(directory, entry));
|
|
146
|
+
if (Date.now() - mtimeMs > 86_400_000) await rm(join(directory, entry), { force: true });
|
|
147
|
+
}
|
|
148
|
+
return removed.length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let document;
|
|
152
|
+
try {
|
|
153
|
+
document = await loadConfig(configFile);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
fail(\`settings could not be read: \${error.code ?? ''} \${error.message}\`);
|
|
156
|
+
}
|
|
157
|
+
const connections = document.options['dokebi-db']?.connections ?? {};
|
|
158
|
+
const done = [];
|
|
159
|
+
const failed = [];
|
|
160
|
+
const skipped = [];
|
|
161
|
+
let removed = 0;
|
|
162
|
+
for (const [name, connection] of Object.entries(connections)) {
|
|
163
|
+
if (only && !only.includes(name)) continue;
|
|
164
|
+
// options.dokebi-db.connections.<name> = { adapter, options: { url, … } }
|
|
165
|
+
const url = typeof connection?.options?.url === 'string' ? connection.options.url : undefined;
|
|
166
|
+
if (!url || !kindOf(url)) {
|
|
167
|
+
skipped.push({ connection: name, reason: url ? 'not postgres or mongodb' : 'no url' });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
await backup(name, url, done);
|
|
172
|
+
removed += await prune(name);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
failed.push({ connection: name, error: error.message });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const ok = failed.length === 0 && done.length > 0;
|
|
178
|
+
process.stdout.write(
|
|
179
|
+
\`\${JSON.stringify({ ok, backups: done, failed, skipped, removed, ms: Date.now() - started })}\\n\`,
|
|
180
|
+
);
|
|
181
|
+
if (!ok) process.exitCode = 1;
|
|
182
|
+
`;
|
|
183
|
+
/** deploy/restore-drill.mjs */
|
|
184
|
+
export const RESTORE_DRILL_SCRIPT = `// Restore drill (D-0184): proves the newest backup of a connection can be restored. It restores
|
|
185
|
+
// it into DOKEBI_RESTORE_URL (an empty scratch database, never the live one: refused when it is
|
|
186
|
+
// the same database) and compares the restored database with the live one: tables or
|
|
187
|
+
// collections, and the rows of the migration history (dokebi_migrations). One JSON line; exit 1
|
|
188
|
+
// when the restore failed or the restored database is empty. Run it every month and after a
|
|
189
|
+
// change of the PostgreSQL or MongoDB version.
|
|
190
|
+
//
|
|
191
|
+
// DOKEBI_CONFIG=/etc/<app>/<instance>/dokebi.yaml DOKEBI_SECRETS=/etc/<app>/<instance>/secrets.yaml \\
|
|
192
|
+
// DOKEBI_BACKUP_DIR=/var/backups/<app>/<instance> DOKEBI_RESTORE_URL=postgres://...<scratch db> \\
|
|
193
|
+
// node deploy/restore-drill.mjs
|
|
194
|
+
//
|
|
195
|
+
// DOKEBI_RESTORE_CONNECTION picks the connection (default main). DOKEBI_RESTORE_URL is a secret:
|
|
196
|
+
// put it in the environment of this run only (a systemd-run --property=EnvironmentFile=...).
|
|
197
|
+
import { spawn } from 'node:child_process';
|
|
198
|
+
import { createReadStream } from 'node:fs';
|
|
199
|
+
import { readdir, rm, writeFile } from 'node:fs/promises';
|
|
200
|
+
import { join } from 'node:path';
|
|
201
|
+
import { loadConfig } from '@dokebi/core';
|
|
202
|
+
|
|
203
|
+
process.umask(0o077);
|
|
204
|
+
const started = Date.now();
|
|
205
|
+
function fail(message, extra = {}) {
|
|
206
|
+
process.stdout.write(\`\${JSON.stringify({ ok: false, error: message, ...extra })}\\n\`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
const configFile = process.env.DOKEBI_CONFIG;
|
|
210
|
+
const folder = process.env.DOKEBI_BACKUP_DIR;
|
|
211
|
+
const target = process.env.DOKEBI_RESTORE_URL;
|
|
212
|
+
const name = process.env.DOKEBI_RESTORE_CONNECTION || 'main';
|
|
213
|
+
if (!configFile || !folder || !target)
|
|
214
|
+
fail('DOKEBI_CONFIG, DOKEBI_BACKUP_DIR and DOKEBI_RESTORE_URL must be set');
|
|
215
|
+
|
|
216
|
+
let document;
|
|
217
|
+
try {
|
|
218
|
+
document = await loadConfig(configFile);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
fail(\`settings could not be read: \${error.code ?? ''} \${error.message}\`);
|
|
221
|
+
}
|
|
222
|
+
const live = document.options['dokebi-db']?.connections?.[name]?.options?.url;
|
|
223
|
+
if (typeof live !== 'string') fail(\`no url for the dokebi-db connection \${name}\`);
|
|
224
|
+
const scheme = (url) => url.slice(0, url.indexOf(':')).toLowerCase();
|
|
225
|
+
const kind = ['postgres', 'postgresql'].includes(scheme(live))
|
|
226
|
+
? 'postgres'
|
|
227
|
+
: ['mongodb', 'mongodb+srv'].includes(scheme(live))
|
|
228
|
+
? 'mongodb'
|
|
229
|
+
: fail(\`the connection \${name} is not postgres or mongodb\`);
|
|
230
|
+
if (kind !== (['postgres', 'postgresql'].includes(scheme(target)) ? 'postgres' : 'mongodb'))
|
|
231
|
+
fail('DOKEBI_RESTORE_URL is not the same kind of database as the connection');
|
|
232
|
+
const where = (url) => {
|
|
233
|
+
const parsed = new URL(url);
|
|
234
|
+
return \`\${parsed.hostname}:\${parsed.port}/\${parsed.pathname.slice(1)}\`;
|
|
235
|
+
};
|
|
236
|
+
if (where(live) === where(target)) fail('DOKEBI_RESTORE_URL names the live database; use a scratch one');
|
|
237
|
+
|
|
238
|
+
const extension = kind === 'postgres' ? '.pgdump' : '.archive.gz';
|
|
239
|
+
let entries = [];
|
|
240
|
+
try {
|
|
241
|
+
entries = await readdir(join(folder, name));
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error.code !== 'ENOENT') throw error;
|
|
244
|
+
}
|
|
245
|
+
const files = entries
|
|
246
|
+
.filter((file) => /^\\d{8}T\\d{6}Z/.test(file) && file.endsWith(extension))
|
|
247
|
+
.sort();
|
|
248
|
+
const newest = files.at(-1);
|
|
249
|
+
if (!newest) fail(\`no backup of \${name} in \${join(folder, name)}\`);
|
|
250
|
+
const backup = join(folder, name, newest);
|
|
251
|
+
|
|
252
|
+
function run(command, args, env, input) {
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
let stderr = '';
|
|
255
|
+
const child = spawn(command, args, { env, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
256
|
+
child.stderr.setEncoding('utf8');
|
|
257
|
+
child.stderr.on('data', (chunk) => {
|
|
258
|
+
stderr = (stderr + chunk).slice(-4000);
|
|
259
|
+
});
|
|
260
|
+
child.once('error', (error) => reject(new Error(\`\${command} could not run: \${error.message}\`)));
|
|
261
|
+
// A tool that stops reading (it failed) breaks the pipe: its exit code says why.
|
|
262
|
+
child.stdin.on('error', (error) => {
|
|
263
|
+
stderr = \`\${stderr}\\n(input: \${error.code ?? error.message})\`.slice(-4000);
|
|
264
|
+
});
|
|
265
|
+
const source = createReadStream(input);
|
|
266
|
+
source.once('error', (error) => {
|
|
267
|
+
child.kill();
|
|
268
|
+
reject(new Error(\`the backup could not be read: \${error.message}\`));
|
|
269
|
+
});
|
|
270
|
+
child.once('close', (code, signal) =>
|
|
271
|
+
code === 0
|
|
272
|
+
? resolve()
|
|
273
|
+
: reject(new Error(\`\${command} exited with \${code ?? signal}: \${stderr.trim()}\`)),
|
|
274
|
+
);
|
|
275
|
+
source.pipe(child.stdin);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function postgresCounts(url) {
|
|
280
|
+
const { default: pg } = await import('pg');
|
|
281
|
+
const client = new pg.Client({ connectionString: url });
|
|
282
|
+
await client.connect();
|
|
283
|
+
try {
|
|
284
|
+
const tables = await client.query(
|
|
285
|
+
"select count(*)::int as n from information_schema.tables where table_type = 'BASE TABLE' and table_schema not in ('pg_catalog', 'information_schema')",
|
|
286
|
+
);
|
|
287
|
+
const history = await client.query("select to_regclass('dokebi_migrations') is not null as present");
|
|
288
|
+
const migrations = history.rows[0].present
|
|
289
|
+
? (await client.query('select count(*)::int as n from dokebi_migrations')).rows[0].n
|
|
290
|
+
: null;
|
|
291
|
+
return { tables: tables.rows[0].n, migrations };
|
|
292
|
+
} finally {
|
|
293
|
+
await client.end();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function mongoCounts(url) {
|
|
298
|
+
const { MongoClient } = await import('mongodb');
|
|
299
|
+
const client = new MongoClient(url);
|
|
300
|
+
await client.connect();
|
|
301
|
+
try {
|
|
302
|
+
const db = client.db();
|
|
303
|
+
const collections = (await db.listCollections({}, { nameOnly: true }).toArray()).filter(
|
|
304
|
+
(item) => !item.name.startsWith('system.'),
|
|
305
|
+
);
|
|
306
|
+
const migrations = collections.some((item) => item.name === 'dokebi_migrations')
|
|
307
|
+
? await db.collection('dokebi_migrations').countDocuments()
|
|
308
|
+
: null;
|
|
309
|
+
return { tables: collections.length, migrations };
|
|
310
|
+
} finally {
|
|
311
|
+
await client.close();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
if (kind === 'postgres') {
|
|
317
|
+
const parsed = new URL(target);
|
|
318
|
+
const password = decodeURIComponent(parsed.password);
|
|
319
|
+
parsed.password = '';
|
|
320
|
+
await run(
|
|
321
|
+
'pg_restore',
|
|
322
|
+
['--clean', '--if-exists', '--no-owner', '--no-privileges', \`--dbname=\${parsed.href}\`],
|
|
323
|
+
{ ...process.env, PGPASSWORD: password },
|
|
324
|
+
backup,
|
|
325
|
+
);
|
|
326
|
+
} else {
|
|
327
|
+
const from = new URL(live).pathname.slice(1);
|
|
328
|
+
const to = new URL(target).pathname.slice(1);
|
|
329
|
+
// A database in the URI would act as a --db filter (nothing of the backup matches it): the
|
|
330
|
+
// URI names the server only, --nsFrom/--nsTo the databases.
|
|
331
|
+
const server = new URL(target);
|
|
332
|
+
server.pathname = '/';
|
|
333
|
+
const options = join(folder, name, '.restore-drill.mongorestore.yaml');
|
|
334
|
+
await writeFile(options, \`uri: \${JSON.stringify(server.href)}\\n\`, { mode: 0o600 });
|
|
335
|
+
try {
|
|
336
|
+
await run(
|
|
337
|
+
'mongorestore',
|
|
338
|
+
[\`--config=\${options}\`, '--archive', '--gzip', '--drop', \`--nsFrom=\${from}.*\`, \`--nsTo=\${to}.*\`],
|
|
339
|
+
process.env,
|
|
340
|
+
backup,
|
|
341
|
+
);
|
|
342
|
+
} finally {
|
|
343
|
+
await rm(options, { force: true });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
fail(error.message, { backup });
|
|
348
|
+
}
|
|
349
|
+
const count = kind === 'postgres' ? postgresCounts : mongoCounts;
|
|
350
|
+
const [restored, current] = await Promise.all([count(target), count(live)]);
|
|
351
|
+
const ok = restored.tables > 0;
|
|
352
|
+
const notes = [];
|
|
353
|
+
if (restored.tables !== current.tables)
|
|
354
|
+
notes.push(\`the live database has \${current.tables} tables or collections, the backup \${restored.tables}\`);
|
|
355
|
+
if (restored.migrations !== current.migrations)
|
|
356
|
+
notes.push('the live migration history differs from the backup (a migration ran since)');
|
|
357
|
+
process.stdout.write(
|
|
358
|
+
\`\${JSON.stringify({ ok, connection: name, kind, backup, restored, live: current, notes, ms: Date.now() - started })}\\n\`,
|
|
359
|
+
);
|
|
360
|
+
if (!ok) process.exitCode = 1;
|
|
361
|
+
`;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Where one instance lives on the server. */
|
|
2
|
+
export interface InstanceLayout {
|
|
3
|
+
readonly app: string;
|
|
4
|
+
readonly instance: string;
|
|
5
|
+
/** Local port of the app (127.0.0.1). */
|
|
6
|
+
readonly port: number;
|
|
7
|
+
/** systemd unit, system user and journald identifier; compose project name. */
|
|
8
|
+
readonly unit: string;
|
|
9
|
+
readonly settings: string;
|
|
10
|
+
readonly secrets: string;
|
|
11
|
+
readonly code: string;
|
|
12
|
+
readonly data: string;
|
|
13
|
+
readonly logs: string;
|
|
14
|
+
readonly backups: string;
|
|
15
|
+
/** The suggested database name: <app>_<instance>. */
|
|
16
|
+
readonly database: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function layout(app: string, instance: string, port: number): InstanceLayout;
|
|
19
|
+
export declare function systemdUnit(at: InstanceLayout): string;
|
|
20
|
+
export declare function backupService(at: InstanceLayout): string;
|
|
21
|
+
export declare function backupTimer(at: InstanceLayout): string;
|
|
22
|
+
export declare function composeFile(at: InstanceLayout): string;
|
|
23
|
+
export interface GuideInput {
|
|
24
|
+
readonly app: string;
|
|
25
|
+
readonly target: 'systemd' | 'compose';
|
|
26
|
+
readonly instances: readonly InstanceLayout[];
|
|
27
|
+
/** The installed plugin ids. */
|
|
28
|
+
readonly plugins: readonly string[];
|
|
29
|
+
/** The secret paths the samples leave to the secrets file, per instance (the same list). */
|
|
30
|
+
readonly secrets: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
/** DEPLOY.md: the instance table, setup, a release, rollback, backups (D-0184 §5, §6). */
|
|
33
|
+
export declare function deployGuide(input: GuideInput): string;
|
|
34
|
+
export interface ProxyInput {
|
|
35
|
+
readonly at: InstanceLayout;
|
|
36
|
+
readonly domains: readonly string[];
|
|
37
|
+
readonly live: string;
|
|
38
|
+
readonly ready: string;
|
|
39
|
+
/** Largest request body the app takes, in bytes. */
|
|
40
|
+
readonly maxBodyBytes: number;
|
|
41
|
+
/** The proxy's read timeout, seconds (long when the app keeps SSE or WebSocket connections). */
|
|
42
|
+
readonly readTimeoutSeconds: number;
|
|
43
|
+
readonly streams: boolean;
|
|
44
|
+
}
|
|
45
|
+
export declare function caddyfile(input: ProxyInput): string;
|
|
46
|
+
export declare function nginxConf(input: ProxyInput): string;
|