hazo_env 0.6.0 → 0.8.0
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/CHANGE_LOG.md +43 -0
- package/README.md +21 -101
- package/dist/cli.js +109 -207
- package/dist/envsync/dotenv-merge.d.ts +24 -0
- package/dist/envsync/dotenv-merge.d.ts.map +1 -0
- package/dist/envsync/dotenv-merge.js +35 -0
- package/dist/envsync/engine.d.ts +77 -0
- package/dist/envsync/engine.d.ts.map +1 -0
- package/dist/envsync/engine.js +155 -0
- package/dist/envsync/exec.d.ts +21 -0
- package/dist/envsync/exec.d.ts.map +1 -0
- package/dist/envsync/exec.js +62 -0
- package/dist/envsync/guards.d.ts +12 -0
- package/dist/envsync/guards.d.ts.map +1 -0
- package/dist/envsync/guards.js +20 -0
- package/dist/envsync/lock.d.ts +12 -0
- package/dist/envsync/lock.d.ts.map +1 -0
- package/dist/envsync/lock.js +84 -0
- package/dist/envsync/retention.d.ts +17 -0
- package/dist/envsync/retention.d.ts.map +1 -0
- package/dist/envsync/retention.js +52 -0
- package/dist/envsync/service.d.ts +21 -0
- package/dist/envsync/service.d.ts.map +1 -0
- package/dist/envsync/service.js +406 -0
- package/dist/index.d.ts +0 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -13
- package/dist/migrate/run.d.ts +13 -0
- package/dist/migrate/run.d.ts.map +1 -1
- package/dist/migrate/run.js +18 -1
- package/dist/resolve/envsync.d.ts +35 -0
- package/dist/resolve/envsync.d.ts.map +1 -0
- package/dist/resolve/envsync.js +77 -0
- package/dist/types/index.d.ts +0 -80
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +7 -7
- package/config/hazo_env_masking.ini.sample +0 -19
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// hazo_env/src/envsync/service.ts — HTTP front-end for envsync ("hazo-env serve")
|
|
2
|
+
//
|
|
3
|
+
// Plain node:http server exposing downloadDb/uploadDb/downloadFiles/uploadFiles
|
|
4
|
+
// over a tiny JSON API plus a self-contained HTML control page. This package
|
|
5
|
+
// only depends on dotenv + picocolors (see package.json) — no framework, so
|
|
6
|
+
// http.createServer is the right level of effort here, matching the
|
|
7
|
+
// "self-contained ops tool" goal from the envsync plan.
|
|
8
|
+
//
|
|
9
|
+
// The CLI (`hazo-env sync ...`) and this service are two independent
|
|
10
|
+
// front-ends over the same envsync/engine.ts functions — neither wraps the
|
|
11
|
+
// other.
|
|
12
|
+
//
|
|
13
|
+
// Security model:
|
|
14
|
+
// - Every route requires `Authorization: Bearer <HAZO_ENVSYNC_TOKEN>`,
|
|
15
|
+
// including GET /status and GET / — simpler and safer than deciding
|
|
16
|
+
// which routes are "safe" to leave open for a single-operator ops tool.
|
|
17
|
+
// (Caveat: this means the control page's *own* initial load also needs
|
|
18
|
+
// the header, which a plain browser navigation can't set — operators are
|
|
19
|
+
// expected to reach it via a client that can set headers, e.g. curl, an
|
|
20
|
+
// HTTP client, or a browser extension. The page's client-side JS only
|
|
21
|
+
// needs the token for the fetch() calls it makes after that.)
|
|
22
|
+
// - Binds to 127.0.0.1 by default (HAZO_ENVSYNC_BIND to override).
|
|
23
|
+
// - Every write op runs inside envsync/lock.ts's withLock — a second
|
|
24
|
+
// concurrent request gets 409, never a racing second pg_dump/tar.
|
|
25
|
+
// - Destructive ops (uploadDb, uploadFiles when confirm:true) go through
|
|
26
|
+
// the same assertConfirmed/assertNotProd guards the CLI uses — the HTTP
|
|
27
|
+
// layer adds no bypass.
|
|
28
|
+
import http from 'node:http';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { resolveEnvsyncConfig } from '../resolve/envsync.js';
|
|
32
|
+
import { downloadDb, uploadDb, downloadFiles, uploadFiles } from './engine.js';
|
|
33
|
+
import { withLock, EnvsyncLockError } from './lock.js';
|
|
34
|
+
import { pruneWorkDir } from './retention.js';
|
|
35
|
+
// Arbitrary high port in the range unlikely to collide with other local
|
|
36
|
+
// services; no particular significance beyond "pick one and document it".
|
|
37
|
+
const DEFAULT_PORT = 7845;
|
|
38
|
+
const DEFAULT_BIND = '127.0.0.1';
|
|
39
|
+
// Caps a JSON request body at 1MB — these bodies are a couple of string
|
|
40
|
+
// fields at most, this is purely an abuse/DoS guard.
|
|
41
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
42
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// small HTTP helpers
|
|
44
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
function sendJson(res, status, body) {
|
|
46
|
+
const text = JSON.stringify(body);
|
|
47
|
+
res.writeHead(status, {
|
|
48
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
49
|
+
'Content-Length': Buffer.byteLength(text),
|
|
50
|
+
});
|
|
51
|
+
res.end(text);
|
|
52
|
+
}
|
|
53
|
+
function sendHtml(res, status, html) {
|
|
54
|
+
res.writeHead(status, {
|
|
55
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
56
|
+
'Content-Length': Buffer.byteLength(html),
|
|
57
|
+
});
|
|
58
|
+
res.end(html);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Errors thrown by the guard helpers (assertConfirmed/assertNotProd) always
|
|
62
|
+
* start with "Refusing" — that's the cheapest reliable way to tell a
|
|
63
|
+
* validation-shaped guard error apart from an unexpected one without a
|
|
64
|
+
* dedicated error class, so guard errors map to 400 and everything else
|
|
65
|
+
* (including real pg_dump/tar failures) maps to 500.
|
|
66
|
+
*/
|
|
67
|
+
function sendEngineError(res, err) {
|
|
68
|
+
if (err instanceof EnvsyncLockError) {
|
|
69
|
+
sendJson(res, 409, { error: 'locked', message: err.message });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
73
|
+
if (message.startsWith('Refusing')) {
|
|
74
|
+
sendJson(res, 400, { error: message });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
sendJson(res, 500, { error: message });
|
|
78
|
+
}
|
|
79
|
+
function readJsonBody(req) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
let size = 0;
|
|
82
|
+
const chunks = [];
|
|
83
|
+
req.on('data', (chunk) => {
|
|
84
|
+
size += chunk.length;
|
|
85
|
+
if (size > MAX_BODY_BYTES) {
|
|
86
|
+
req.destroy();
|
|
87
|
+
reject(new Error('request body too large'));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
chunks.push(chunk);
|
|
91
|
+
});
|
|
92
|
+
req.on('end', () => {
|
|
93
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
94
|
+
if (!text) {
|
|
95
|
+
resolve({});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(text);
|
|
100
|
+
resolve(parsed && typeof parsed === 'object' ? parsed : {});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
reject(new Error('invalid JSON request body'));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
req.on('error', reject);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function requireConfig(res) {
|
|
110
|
+
const cfg = resolveEnvsyncConfig();
|
|
111
|
+
if (!cfg) {
|
|
112
|
+
sendJson(res, 500, { error: 'envsync not configured — missing [envsync] section in hazo_env_config.ini' });
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return cfg;
|
|
116
|
+
}
|
|
117
|
+
function progressLogger(op) {
|
|
118
|
+
return (msg) => console.log(`[envsync:${op}] ${msg}`);
|
|
119
|
+
}
|
|
120
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
// routes
|
|
122
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
function handleStatus(res) {
|
|
124
|
+
const cfg = resolveEnvsyncConfig();
|
|
125
|
+
if (!cfg) {
|
|
126
|
+
sendJson(res, 200, { status: 'unconfigured' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const lockPath = path.join(cfg.work_dir, '.envsync.lock');
|
|
130
|
+
if (!fs.existsSync(lockPath)) {
|
|
131
|
+
sendJson(res, 200, { status: 'idle' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const data = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
136
|
+
sendJson(res, 200, { status: 'busy', op: data.op, since: data.timestamp });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Missing/unreadable lockfile — treat as idle. Actual staleness
|
|
140
|
+
// enforcement (dead-PID clearing) happens inside withLock when an op is
|
|
141
|
+
// attempted; this is just a best-effort status read.
|
|
142
|
+
sendJson(res, 200, { status: 'idle' });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function handleDbDownload(res) {
|
|
146
|
+
const cfg = requireConfig(res);
|
|
147
|
+
if (!cfg)
|
|
148
|
+
return;
|
|
149
|
+
try {
|
|
150
|
+
const result = await withLock(cfg.work_dir, 'download_db', () => downloadDb(cfg, { onProgress: progressLogger('download_db') }));
|
|
151
|
+
pruneWorkDir(cfg.work_dir, { keep: cfg.keep });
|
|
152
|
+
sendJson(res, 200, { ok: true, result });
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
sendEngineError(res, err);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function handleDbUpload(req, res) {
|
|
159
|
+
const cfg = requireConfig(res);
|
|
160
|
+
if (!cfg)
|
|
161
|
+
return;
|
|
162
|
+
let body;
|
|
163
|
+
try {
|
|
164
|
+
body = await readJsonBody(req);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const dumpPath = body['dumpPath'];
|
|
171
|
+
if (typeof dumpPath !== 'string' || !dumpPath) {
|
|
172
|
+
sendJson(res, 400, { error: 'dumpPath is required' });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const result = await withLock(cfg.work_dir, 'upload_db', () => uploadDb(cfg, dumpPath, {
|
|
177
|
+
confirm: body['confirm'] === true,
|
|
178
|
+
allowProd: body['allowProd'] === true,
|
|
179
|
+
onProgress: progressLogger('upload_db'),
|
|
180
|
+
}));
|
|
181
|
+
sendJson(res, 200, result);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
sendEngineError(res, err);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function handleFilesArchive(res) {
|
|
188
|
+
const cfg = requireConfig(res);
|
|
189
|
+
if (!cfg)
|
|
190
|
+
return;
|
|
191
|
+
try {
|
|
192
|
+
const result = await withLock(cfg.work_dir, 'download_files', () => downloadFiles(cfg, { onProgress: progressLogger('download_files') }));
|
|
193
|
+
pruneWorkDir(cfg.work_dir, { keep: cfg.keep });
|
|
194
|
+
sendJson(res, 200, { ok: true, result });
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
sendEngineError(res, err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function handleFilesRestore(req, res) {
|
|
201
|
+
const cfg = requireConfig(res);
|
|
202
|
+
if (!cfg)
|
|
203
|
+
return;
|
|
204
|
+
let body;
|
|
205
|
+
try {
|
|
206
|
+
body = await readJsonBody(req);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const archivePath = body['archivePath'];
|
|
213
|
+
if (typeof archivePath !== 'string' || !archivePath) {
|
|
214
|
+
sendJson(res, 400, { error: 'archivePath is required' });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
// uploadFiles itself returns { ok: false, diff } for the no-confirm
|
|
219
|
+
// dry-run preview — that's a normal 200, not an error.
|
|
220
|
+
const result = await withLock(cfg.work_dir, 'upload_files', () => uploadFiles(cfg, archivePath, {
|
|
221
|
+
confirm: body['confirm'] === true,
|
|
222
|
+
allowProd: body['allowProd'] === true,
|
|
223
|
+
onProgress: progressLogger('upload_files'),
|
|
224
|
+
}));
|
|
225
|
+
sendJson(res, 200, result);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
sendEngineError(res, err);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function controlPageHtml() {
|
|
232
|
+
return `<!doctype html>
|
|
233
|
+
<html lang="en">
|
|
234
|
+
<head>
|
|
235
|
+
<meta charset="utf-8" />
|
|
236
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
237
|
+
<title>hazo_env — envsync control</title>
|
|
238
|
+
<style>
|
|
239
|
+
:root { color-scheme: light dark; }
|
|
240
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
|
|
241
|
+
h1 { font-size: 1.25rem; }
|
|
242
|
+
h2 { font-size: 1rem; }
|
|
243
|
+
section { margin-bottom: 1.5rem; padding: 1rem; border: 1px solid #8884; border-radius: 8px; }
|
|
244
|
+
button { padding: 0.5rem 1rem; margin-right: 0.5rem; cursor: pointer; }
|
|
245
|
+
input[type="text"], input[type="password"] { padding: 0.4rem; width: 100%; box-sizing: border-box; margin-bottom: 0.5rem; }
|
|
246
|
+
pre { background: #8881; padding: 0.75rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
|
247
|
+
label { font-weight: 600; }
|
|
248
|
+
.row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; }
|
|
249
|
+
</style>
|
|
250
|
+
</head>
|
|
251
|
+
<body>
|
|
252
|
+
<h1>hazo_env — envsync control</h1>
|
|
253
|
+
|
|
254
|
+
<section>
|
|
255
|
+
<label for="token">Bearer token</label>
|
|
256
|
+
<div class="row">
|
|
257
|
+
<input type="password" id="token" placeholder="HAZO_ENVSYNC_TOKEN" />
|
|
258
|
+
<button id="saveToken" type="button">Save</button>
|
|
259
|
+
</div>
|
|
260
|
+
<div id="status">status: (unknown)</div>
|
|
261
|
+
</section>
|
|
262
|
+
|
|
263
|
+
<section>
|
|
264
|
+
<h2>Database</h2>
|
|
265
|
+
<div class="row">
|
|
266
|
+
<button id="dbDownload" type="button">Download DB</button>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="row">
|
|
269
|
+
<input type="text" id="dumpPath" placeholder="/path/to/db-<id>.pgdump" />
|
|
270
|
+
</div>
|
|
271
|
+
<div class="row">
|
|
272
|
+
<label><input type="checkbox" id="dbConfirm" /> confirm</label>
|
|
273
|
+
<label><input type="checkbox" id="dbAllowProd" /> allow prod</label>
|
|
274
|
+
<button id="dbUpload" type="button">Upload DB</button>
|
|
275
|
+
</div>
|
|
276
|
+
</section>
|
|
277
|
+
|
|
278
|
+
<section>
|
|
279
|
+
<h2>Files</h2>
|
|
280
|
+
<div class="row">
|
|
281
|
+
<button id="filesArchive" type="button">Archive Files</button>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="row">
|
|
284
|
+
<input type="text" id="archivePath" placeholder="/path/to/files-<id>.tar.gz" />
|
|
285
|
+
</div>
|
|
286
|
+
<div class="row">
|
|
287
|
+
<label><input type="checkbox" id="filesConfirm" /> confirm</label>
|
|
288
|
+
<label><input type="checkbox" id="filesAllowProd" /> allow prod</label>
|
|
289
|
+
<button id="filesRestore" type="button">Restore Files</button>
|
|
290
|
+
</div>
|
|
291
|
+
</section>
|
|
292
|
+
|
|
293
|
+
<pre id="output">(no output yet)</pre>
|
|
294
|
+
|
|
295
|
+
<script>
|
|
296
|
+
function getToken() { return sessionStorage.getItem('hazo_envsync_token') || ''; }
|
|
297
|
+
document.getElementById('token').value = getToken();
|
|
298
|
+
document.getElementById('saveToken').addEventListener('click', function () {
|
|
299
|
+
sessionStorage.setItem('hazo_envsync_token', document.getElementById('token').value);
|
|
300
|
+
refreshStatus();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
function call(method, urlPath, body) {
|
|
304
|
+
var headers = { 'Content-Type': 'application/json' };
|
|
305
|
+
var token = getToken();
|
|
306
|
+
if (token) headers['Authorization'] = 'Bearer ' + token;
|
|
307
|
+
return fetch(urlPath, { method: method, headers: headers, body: body ? JSON.stringify(body) : undefined })
|
|
308
|
+
.then(function (res) { return res.json().catch(function () { return { error: 'invalid response' }; }); })
|
|
309
|
+
.then(function (json) {
|
|
310
|
+
document.getElementById('output').textContent = JSON.stringify(json, null, 2);
|
|
311
|
+
return json;
|
|
312
|
+
})
|
|
313
|
+
.catch(function (err) {
|
|
314
|
+
document.getElementById('output').textContent = 'request failed: ' + err;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function refreshStatus() {
|
|
319
|
+
call('GET', '/status').then(function (json) {
|
|
320
|
+
document.getElementById('status').textContent = 'status: ' + JSON.stringify(json);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
document.getElementById('dbDownload').addEventListener('click', function () { call('POST', '/db/download'); });
|
|
325
|
+
document.getElementById('dbUpload').addEventListener('click', function () {
|
|
326
|
+
call('POST', '/db/upload', {
|
|
327
|
+
dumpPath: document.getElementById('dumpPath').value,
|
|
328
|
+
confirm: document.getElementById('dbConfirm').checked,
|
|
329
|
+
allowProd: document.getElementById('dbAllowProd').checked,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
document.getElementById('filesArchive').addEventListener('click', function () { call('POST', '/files/archive'); });
|
|
333
|
+
document.getElementById('filesRestore').addEventListener('click', function () {
|
|
334
|
+
call('POST', '/files/restore', {
|
|
335
|
+
archivePath: document.getElementById('archivePath').value,
|
|
336
|
+
confirm: document.getElementById('filesConfirm').checked,
|
|
337
|
+
allowProd: document.getElementById('filesAllowProd').checked,
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
refreshStatus();
|
|
342
|
+
</script>
|
|
343
|
+
</body>
|
|
344
|
+
</html>`;
|
|
345
|
+
}
|
|
346
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
347
|
+
// server bootstrap
|
|
348
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
349
|
+
async function dispatch(req, res, token) {
|
|
350
|
+
const auth = req.headers['authorization'];
|
|
351
|
+
if (auth !== `Bearer ${token}`) {
|
|
352
|
+
sendJson(res, 401, { error: 'unauthorized' });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
356
|
+
const method = req.method ?? 'GET';
|
|
357
|
+
if (method === 'GET' && url.pathname === '/status')
|
|
358
|
+
return handleStatus(res);
|
|
359
|
+
if (method === 'GET' && url.pathname === '/')
|
|
360
|
+
return sendHtml(res, 200, controlPageHtml());
|
|
361
|
+
if (method === 'POST' && url.pathname === '/db/download')
|
|
362
|
+
return handleDbDownload(res);
|
|
363
|
+
if (method === 'POST' && url.pathname === '/db/upload')
|
|
364
|
+
return handleDbUpload(req, res);
|
|
365
|
+
if (method === 'POST' && url.pathname === '/files/archive')
|
|
366
|
+
return handleFilesArchive(res);
|
|
367
|
+
if (method === 'POST' && url.pathname === '/files/restore')
|
|
368
|
+
return handleFilesRestore(req, res);
|
|
369
|
+
sendJson(res, 404, { error: 'not_found' });
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Start the envsync HTTP service. Throws synchronously if no bearer token
|
|
373
|
+
* is configured (via opts.token or HAZO_ENVSYNC_TOKEN) — this service must
|
|
374
|
+
* never run unauthenticated.
|
|
375
|
+
*
|
|
376
|
+
* opts override env vars, which makes this testable without real env vars
|
|
377
|
+
* or real ports: pass { port: 0 } for an OS-assigned ephemeral port and read
|
|
378
|
+
* the actual port off `server.address()`.
|
|
379
|
+
*/
|
|
380
|
+
export function startEnvsyncService(opts = {}) {
|
|
381
|
+
const token = opts.token ?? process.env['HAZO_ENVSYNC_TOKEN'];
|
|
382
|
+
if (!token) {
|
|
383
|
+
throw new Error('envsync: HAZO_ENVSYNC_TOKEN is required to start the envsync service — refusing to run with no auth');
|
|
384
|
+
}
|
|
385
|
+
const envPort = process.env['HAZO_ENVSYNC_PORT'];
|
|
386
|
+
const port = opts.port ?? (envPort ? Number(envPort) : DEFAULT_PORT);
|
|
387
|
+
const bind = opts.bind ?? process.env['HAZO_ENVSYNC_BIND'] ?? DEFAULT_BIND;
|
|
388
|
+
const server = http.createServer((req, res) => {
|
|
389
|
+
dispatch(req, res, token).catch((err) => {
|
|
390
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
391
|
+
if (!res.headersSent)
|
|
392
|
+
sendJson(res, 500, { error: message });
|
|
393
|
+
else
|
|
394
|
+
res.end();
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
server.listen(port, bind);
|
|
398
|
+
return {
|
|
399
|
+
server,
|
|
400
|
+
close() {
|
|
401
|
+
return new Promise((resolve, reject) => {
|
|
402
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,18 +5,6 @@ export * from './resolve/secrets.js';
|
|
|
5
5
|
export * from './resolve/connect.js';
|
|
6
6
|
export * from './resolve/files.js';
|
|
7
7
|
export * from './doctor.js';
|
|
8
|
-
export { runMigration } from './migrate/run.js';
|
|
9
|
-
export { clearEnv } from './migrate/clear.js';
|
|
10
|
-
export { verifyFiles } from './migrate/verify.js';
|
|
11
|
-
export { takeSnapshot, restoreSnapshot } from './migrate/snapshot.js';
|
|
12
|
-
export { writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from './migrate/progress.js';
|
|
13
|
-
export { registerMask } from './mask/registry.js';
|
|
14
|
-
export { loadRuleset, syncRulesetFromIni, parseIniRules } from './mask/ruleset.js';
|
|
15
|
-
export type { MaskRule } from './mask/ruleset.js';
|
|
16
|
-
export type { MaskTransform } from './types/index.js';
|
|
17
8
|
export * from './lib/index.js';
|
|
18
|
-
export { resolveBackupConfig } from './resolve/backup.js';
|
|
19
|
-
export type { BackupConfig } from './resolve/backup.js';
|
|
20
9
|
export { runSsh, assertSafeSshField } from './migrate/ssh-exec.js';
|
|
21
|
-
export { restoreDbViaDump } from './migrate/db-dump-restore.js';
|
|
22
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAEvG,cAAc,kBAAkB,CAAC;AAEjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,aAAa,CAAC;AAE5B,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAEvG,cAAc,kBAAkB,CAAC;AAEjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AAEnC,cAAc,aAAa,CAAC;AAE5B,cAAc,gBAAgB,CAAC;AAE/B,OAAO,EAAE,MAAM,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -11,19 +11,7 @@ export * from './resolve/connect.js';
|
|
|
11
11
|
export * from './resolve/files.js';
|
|
12
12
|
// Doctor
|
|
13
13
|
export * from './doctor.js';
|
|
14
|
-
// Migration engine (Phase 2)
|
|
15
|
-
export { runMigration } from './migrate/run.js';
|
|
16
|
-
export { clearEnv } from './migrate/clear.js';
|
|
17
|
-
export { verifyFiles } from './migrate/verify.js';
|
|
18
|
-
export { takeSnapshot, restoreSnapshot } from './migrate/snapshot.js';
|
|
19
|
-
export { writeMigrationProgress, readMigrationProgress, clearMigrationProgress } from './migrate/progress.js';
|
|
20
|
-
// Masking registry
|
|
21
|
-
export { registerMask } from './mask/registry.js';
|
|
22
|
-
// Masking ruleset
|
|
23
|
-
export { loadRuleset, syncRulesetFromIni, parseIniRules } from './mask/ruleset.js';
|
|
24
14
|
// Lib
|
|
25
15
|
export * from './lib/index.js';
|
|
26
|
-
//
|
|
27
|
-
export { resolveBackupConfig } from './resolve/backup.js';
|
|
16
|
+
// Generic SSH-exec utilities (retained: shared infra, not migration-specific)
|
|
28
17
|
export { runSsh, assertSafeSshField } from './migrate/ssh-exec.js';
|
|
29
|
-
export { restoreDbViaDump } from './migrate/db-dump-restore.js';
|
package/dist/migrate/run.d.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
1
|
import type { MigrationRequest, MigrationResult } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the effective table list handed to copyDb.
|
|
4
|
+
*
|
|
5
|
+
* `'*'` (and an unset request) both mean "all tables". The SQL path can
|
|
6
|
+
* auto-discover that set, but the PostgREST path cannot (no FK ordering without
|
|
7
|
+
* an explicit list) and rejects `'*'`. So when the request asks for "all", we
|
|
8
|
+
* fall back to the configured `[migrate] tables` list — the app's canonical
|
|
9
|
+
* FK-ordered definition of "all". Only when no config list exists do we pass
|
|
10
|
+
* `'*'` through (SQL auto-discovers; PostgREST then errors as designed).
|
|
11
|
+
*
|
|
12
|
+
* An explicit `string[]` from the request always wins.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveTableList(reqTables: '*' | string[] | undefined, configTables: string[] | undefined): '*' | string[];
|
|
2
15
|
export declare function runMigration(req: MigrationRequest): Promise<MigrationResult>;
|
|
3
16
|
//# sourceMappingURL=run.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/migrate/run.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAkC,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/migrate/run.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAkC,MAAM,mBAAmB,CAAC;AAU3G;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,GAAG,GAAG,MAAM,EAAE,GAAG,SAAS,EACrC,YAAY,EAAE,MAAM,EAAE,GAAG,SAAS,GACjC,GAAG,GAAG,MAAM,EAAE,CAGhB;AAkDD,wBAAsB,YAAY,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAyMlF"}
|
package/dist/migrate/run.js
CHANGED
|
@@ -22,6 +22,23 @@ function emitProgress(req, p) {
|
|
|
22
22
|
writeMigrationProgress(req.progressDir, req.jobId, p);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the effective table list handed to copyDb.
|
|
27
|
+
*
|
|
28
|
+
* `'*'` (and an unset request) both mean "all tables". The SQL path can
|
|
29
|
+
* auto-discover that set, but the PostgREST path cannot (no FK ordering without
|
|
30
|
+
* an explicit list) and rejects `'*'`. So when the request asks for "all", we
|
|
31
|
+
* fall back to the configured `[migrate] tables` list — the app's canonical
|
|
32
|
+
* FK-ordered definition of "all". Only when no config list exists do we pass
|
|
33
|
+
* `'*'` through (SQL auto-discovers; PostgREST then errors as designed).
|
|
34
|
+
*
|
|
35
|
+
* An explicit `string[]` from the request always wins.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveTableList(reqTables, configTables) {
|
|
38
|
+
if (reqTables && reqTables !== '*')
|
|
39
|
+
return reqTables;
|
|
40
|
+
return configTables ?? '*';
|
|
41
|
+
}
|
|
25
42
|
// Resolve the SQLite driver — prefer better-sqlite3 in tests when env var is set
|
|
26
43
|
function getSqliteDriver() {
|
|
27
44
|
if (process.env['HAZO_ENV_TEST_SQLITE_DRIVER'] === 'better-sqlite3')
|
|
@@ -192,7 +209,7 @@ export async function runMigration(req) {
|
|
|
192
209
|
const scrubHook = await buildScrubHook(req.to, scrubMode, tgtAdapter);
|
|
193
210
|
const result = await copyDb(srcAdapter, tgtAdapter, {
|
|
194
211
|
type: fromDbConfig.type,
|
|
195
|
-
tables: req.tables
|
|
212
|
+
tables: resolveTableList(req.tables, migrateConfig.tables),
|
|
196
213
|
preserve: migrateConfig.preserve,
|
|
197
214
|
pkOverrides: migrateConfig.pkOverrides,
|
|
198
215
|
scrubHook,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface EnvsyncConfig {
|
|
2
|
+
/** Postgres database that downloadDb/downloadFiles pull FROM */
|
|
3
|
+
source_db: string;
|
|
4
|
+
/** Postgres database that uploadDb drops/recreates/restores INTO */
|
|
5
|
+
target_db: string;
|
|
6
|
+
/** Owner passed to createdb -O */
|
|
7
|
+
owner: string;
|
|
8
|
+
/** Local files root that downloadFiles tars and uploadFiles overwrites */
|
|
9
|
+
files_root: string;
|
|
10
|
+
/** Local scratch dir for dumps/archives + the .envsync.lock file */
|
|
11
|
+
work_dir: string;
|
|
12
|
+
/** How many of each file kind (db dump, files archive) to retain */
|
|
13
|
+
keep: number;
|
|
14
|
+
/** Shell command run before dropdb/createdb/pg_restore, if configured */
|
|
15
|
+
pre_restore_cmd?: string;
|
|
16
|
+
/** Shell command run after a successful pg_restore, if configured */
|
|
17
|
+
post_restore_cmd?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Names guarded by assertNotProd. Defaults to just [source_db]: the
|
|
20
|
+
* assumption is that the environment this config downloads FROM is the
|
|
21
|
+
* one that must never be accidentally overwritten by uploadDb/uploadFiles.
|
|
22
|
+
* (No separate INI knob for this in Phase 1 — not required by spec.)
|
|
23
|
+
*/
|
|
24
|
+
prodDbNames: string[];
|
|
25
|
+
/** Every KEY=value pair from [migrate.env_overrides], ${VAR}-expanded */
|
|
26
|
+
envOverrides: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the [envsync] section from hazo_env_config.ini.
|
|
30
|
+
* Returns null when the section is missing, the config file is missing, or
|
|
31
|
+
* any required field (source_db, target_db, owner, files_root, work_dir) is
|
|
32
|
+
* absent — mirrors resolveBackupConfig's null-on-missing behavior exactly.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveEnvsyncConfig(): EnvsyncConfig | null;
|
|
35
|
+
//# sourceMappingURL=envsync.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"envsync.d.ts","sourceRoot":"","sources":["../../src/resolve/envsync.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,aAAa;IAC5B,gEAAgE;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,qEAAqE;IACrE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,yEAAyE;IACzE,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAgBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,IAAI,aAAa,GAAG,IAAI,CAuD3D"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// hazo_env/src/resolve/envsync.ts — local envsync (pg_dump/pg_restore/tar) config resolver
|
|
2
|
+
//
|
|
3
|
+
// Reads [envsync] and [migrate.env_overrides] from config/hazo_env_config.ini.
|
|
4
|
+
// Follows the exact null-on-missing pattern of resolve/backup.ts.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { HazoConfig } from 'hazo_config/server';
|
|
7
|
+
import { resolveFilesConfig } from './files.js';
|
|
8
|
+
const DEFAULT_KEEP = 3;
|
|
9
|
+
function tryLoadEnvConfig() {
|
|
10
|
+
try {
|
|
11
|
+
return new HazoConfig({ filePath: path.resolve(process.cwd(), 'config', 'hazo_env_config.ini') });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function expandEnvVars(value, extraVars) {
|
|
18
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, name) => extraVars[name] ?? process.env[name] ?? '');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the [envsync] section from hazo_env_config.ini.
|
|
22
|
+
* Returns null when the section is missing, the config file is missing, or
|
|
23
|
+
* any required field (source_db, target_db, owner, files_root, work_dir) is
|
|
24
|
+
* absent — mirrors resolveBackupConfig's null-on-missing behavior exactly.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveEnvsyncConfig() {
|
|
27
|
+
const config = tryLoadEnvConfig();
|
|
28
|
+
if (!config)
|
|
29
|
+
return null;
|
|
30
|
+
const section = config.getSection('envsync');
|
|
31
|
+
if (!section)
|
|
32
|
+
return null;
|
|
33
|
+
const source_db_raw = section['source_db'];
|
|
34
|
+
const target_db_raw = section['target_db'];
|
|
35
|
+
const owner_raw = section['owner'];
|
|
36
|
+
const files_root_raw = section['files_root'];
|
|
37
|
+
const work_dir_raw = section['work_dir'];
|
|
38
|
+
const keep_raw = section['keep'];
|
|
39
|
+
const pre_restore_cmd_raw = section['pre_restore_cmd'];
|
|
40
|
+
const post_restore_cmd_raw = section['post_restore_cmd'];
|
|
41
|
+
if (!source_db_raw || !target_db_raw || !owner_raw || !files_root_raw || !work_dir_raw)
|
|
42
|
+
return null;
|
|
43
|
+
// Resolve DATA_ROOT for ${DATA_ROOT} substitution in files_root, the same
|
|
44
|
+
// way resolve/connect.ts does for db.<env>.sqlite.database_path.
|
|
45
|
+
let dataRoot = 'app_data';
|
|
46
|
+
try {
|
|
47
|
+
dataRoot = resolveFilesConfig().local.basePath;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// fallback: use relative default
|
|
51
|
+
}
|
|
52
|
+
const extraVars = { DATA_ROOT: dataRoot };
|
|
53
|
+
const source_db = expandEnvVars(source_db_raw, extraVars);
|
|
54
|
+
const keepParsed = keep_raw ? parseInt(keep_raw, 10) : NaN;
|
|
55
|
+
const keep = Number.isFinite(keepParsed) && keepParsed > 0 ? keepParsed : DEFAULT_KEEP;
|
|
56
|
+
const envOverrides = {};
|
|
57
|
+
const overridesSection = config.getSection('migrate.env_overrides');
|
|
58
|
+
if (overridesSection) {
|
|
59
|
+
for (const [key, value] of Object.entries(overridesSection)) {
|
|
60
|
+
if (typeof value === 'string') {
|
|
61
|
+
envOverrides[key] = expandEnvVars(value, extraVars);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
source_db,
|
|
67
|
+
target_db: expandEnvVars(target_db_raw, extraVars),
|
|
68
|
+
owner: expandEnvVars(owner_raw, extraVars),
|
|
69
|
+
files_root: expandEnvVars(files_root_raw, extraVars),
|
|
70
|
+
work_dir: expandEnvVars(work_dir_raw, extraVars),
|
|
71
|
+
keep,
|
|
72
|
+
pre_restore_cmd: pre_restore_cmd_raw ? expandEnvVars(pre_restore_cmd_raw, extraVars) : undefined,
|
|
73
|
+
post_restore_cmd: post_restore_cmd_raw ? expandEnvVars(post_restore_cmd_raw, extraVars) : undefined,
|
|
74
|
+
prodDbNames: [source_db],
|
|
75
|
+
envOverrides,
|
|
76
|
+
};
|
|
77
|
+
}
|