hazo_env 0.6.1 → 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 +37 -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/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 +1 -1
- package/config/hazo_env_masking.ini.sample +0 -19
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// hazo_env/src/envsync/dotenv-merge.ts — pure env-merge/diff helpers for uploadFiles
|
|
2
|
+
//
|
|
3
|
+
// Split out from engine.ts so the merge/diff/override logic is unit-testable
|
|
4
|
+
// with plain fixture strings, no filesystem or tar involved.
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
/** Parse dotenv-style KEY=VALUE text into a plain record. */
|
|
7
|
+
export function parseDotenvText(text) {
|
|
8
|
+
return dotenv.parse(text);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Merge an archive's .env.local contents with operator-configured overrides.
|
|
12
|
+
* Overrides always win.
|
|
13
|
+
*/
|
|
14
|
+
export function mergeEnvOverrides(archiveEnv, overrides) {
|
|
15
|
+
return { ...archiveEnv, ...overrides };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Diff the merged (about-to-be-written) env against the CURRENT target
|
|
19
|
+
* .env.local content — this is "what's about to change" from the
|
|
20
|
+
* operator's point of view, since currentEnv is what uploadFiles is about
|
|
21
|
+
* to overwrite. (Diffing against the archive's original values instead
|
|
22
|
+
* would show "what changed since the download", which is less useful when
|
|
23
|
+
* deciding whether to confirm an upload.)
|
|
24
|
+
*/
|
|
25
|
+
export function diffEnvAgainstCurrent(merged, currentEnv) {
|
|
26
|
+
return Object.keys(merged)
|
|
27
|
+
.sort()
|
|
28
|
+
.map((key) => ({ key, before: currentEnv[key], after: merged[key] }));
|
|
29
|
+
}
|
|
30
|
+
/** Serialize a record back into KEY=VALUE dotenv text. */
|
|
31
|
+
export function serializeDotenv(env) {
|
|
32
|
+
return Object.entries(env)
|
|
33
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
34
|
+
.join('\n') + (Object.keys(env).length ? '\n' : '');
|
|
35
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type EnvOverrideDiff } from './dotenv-merge.js';
|
|
2
|
+
import type { EnvsyncConfig } from '../resolve/envsync.js';
|
|
3
|
+
export type { EnvOverrideDiff };
|
|
4
|
+
export interface EnvsyncFileResult {
|
|
5
|
+
id: string;
|
|
6
|
+
path: string;
|
|
7
|
+
bytes: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* pg_dump the configured source_db to <work_dir>/db-<id>.pgdump.
|
|
11
|
+
*/
|
|
12
|
+
export declare function downloadDb(cfg: EnvsyncConfig, opts?: {
|
|
13
|
+
onProgress?: (msg: string) => void;
|
|
14
|
+
id?: string;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
}): Promise<EnvsyncFileResult>;
|
|
17
|
+
/**
|
|
18
|
+
* Drop, recreate, and pg_restore the configured target_db from dumpPath.
|
|
19
|
+
* Guarded by assertConfirmed + assertNotProd — never runs unconfirmed or
|
|
20
|
+
* against a target that looks like production.
|
|
21
|
+
*/
|
|
22
|
+
export declare function uploadDb(cfg: EnvsyncConfig, dumpPath: string, opts: {
|
|
23
|
+
confirm?: boolean;
|
|
24
|
+
allowProd?: boolean;
|
|
25
|
+
onProgress?: (msg: string) => void;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
ok: true;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* tar cfg.files_root into <work_dir>/files-<id>.tar.gz, plus the current
|
|
31
|
+
* .env.local (from process.cwd()) added under the fixed member name
|
|
32
|
+
* __env.local.
|
|
33
|
+
*
|
|
34
|
+
* Approach: stage a temp dir containing ONLY __env.local, then run a single
|
|
35
|
+
* `tar -czf out -C files_root . -C stageDir __env.local` invocation — tar
|
|
36
|
+
* accepts multiple -C flags, each retargeting the base dir for the file
|
|
37
|
+
* args that follow it, so one archive gets both "everything in files_root"
|
|
38
|
+
* and "one file from elsewhere" without a second tar --append pass (which
|
|
39
|
+
* would mean rewriting/recompressing the whole gzip stream).
|
|
40
|
+
*/
|
|
41
|
+
export declare function downloadFiles(cfg: EnvsyncConfig, opts?: {
|
|
42
|
+
onProgress?: (msg: string) => void;
|
|
43
|
+
id?: string;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}): Promise<EnvsyncFileResult>;
|
|
46
|
+
/**
|
|
47
|
+
* Preview or apply an archive from downloadFiles into cfg.files_root +
|
|
48
|
+
* process.cwd()/.env.local.
|
|
49
|
+
*
|
|
50
|
+
* Always: extract __env.local from the archive, merge cfg.envOverrides on
|
|
51
|
+
* top (overrides win), and diff the merged result against the CURRENT
|
|
52
|
+
* target .env.local — i.e. "what's about to change" (see dotenv-merge.ts
|
|
53
|
+
* for why that's the more useful diff direction than "vs the archive's
|
|
54
|
+
* original values").
|
|
55
|
+
*
|
|
56
|
+
* If opts.confirm is falsy: returns { ok: false, diff } — a dry-run
|
|
57
|
+
* preview, no writes at all.
|
|
58
|
+
*
|
|
59
|
+
* If opts.confirm is truthy: guarded by assertNotProd against
|
|
60
|
+
* cfg.target_db (reusing the same DB-name guard uploadDb uses — files and
|
|
61
|
+
* DB uploads are two halves of syncing INTO the same target environment,
|
|
62
|
+
* so it's the meaningful "is this prod" check available here), then backs
|
|
63
|
+
* up the existing target .env.local, wipes and repopulates files_root from
|
|
64
|
+
* the archive, and writes the merged .env.local.
|
|
65
|
+
*/
|
|
66
|
+
export declare function uploadFiles(cfg: EnvsyncConfig, archivePath: string, opts: {
|
|
67
|
+
confirm?: boolean;
|
|
68
|
+
allowProd?: boolean;
|
|
69
|
+
onProgress?: (msg: string) => void;
|
|
70
|
+
}): Promise<{
|
|
71
|
+
ok: true;
|
|
72
|
+
diff: EnvOverrideDiff[];
|
|
73
|
+
} | {
|
|
74
|
+
ok: false;
|
|
75
|
+
diff: EnvOverrideDiff[];
|
|
76
|
+
}>;
|
|
77
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/envsync/engine.ts"],"names":[],"mappings":"AAaA,OAAO,EAKL,KAAK,eAAe,EACrB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAaD;;GAEG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,aAAa,EAClB,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAE,GAC7E,OAAO,CAAC,iBAAiB,CAAC,CAY5B;AAED;;;;GAIG;AACH,wBAAsB,QAAQ,CAC5B,GAAG,EAAE,aAAa,EAClB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACnF,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAAC,CAwBvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,aAAa,EAClB,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAE,GAC7E,OAAO,CAAC,iBAAiB,CAAC,CA0B5B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,aAAa,EAClB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACnF,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,eAAe,EAAE,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,eAAe,EAAE,CAAA;CAAE,CAAC,CAqDzF"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// hazo_env/src/envsync/engine.ts — local pg_dump/pg_restore/tar orchestration
|
|
2
|
+
//
|
|
3
|
+
// Four pure-ish orchestration functions over an EnvsyncConfig. Everything
|
|
4
|
+
// runs locally via exec.ts's spawn wrapper (no SSH, no bash script string-
|
|
5
|
+
// building) — real argv arrays for pg_dump/pg_restore/dropdb/createdb/tar.
|
|
6
|
+
// Modeled on migrate/db-dump-restore.ts's step shape, minus the SSH layer.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { runCmd, runShell } from './exec.js';
|
|
11
|
+
import { assertFreeSpace } from './retention.js';
|
|
12
|
+
import { assertConfirmed, assertNotProd } from './guards.js';
|
|
13
|
+
import { mergeEnvOverrides, diffEnvAgainstCurrent, parseDotenvText, serializeDotenv, } from './dotenv-merge.js';
|
|
14
|
+
// Free-space floor checked before writing a new dump/archive into work_dir.
|
|
15
|
+
// Not configurable in Phase 1 — a fixed conservative floor is good enough
|
|
16
|
+
// to catch "disk is basically full" before pg_dump/tar fail mid-write.
|
|
17
|
+
const MIN_FREE_BYTES = 500 * 1024 * 1024; // 500 MiB
|
|
18
|
+
const ENV_LOCAL_MEMBER = '__env.local';
|
|
19
|
+
function defaultId(now) {
|
|
20
|
+
return String((now ?? Date.now)());
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* pg_dump the configured source_db to <work_dir>/db-<id>.pgdump.
|
|
24
|
+
*/
|
|
25
|
+
export async function downloadDb(cfg, opts) {
|
|
26
|
+
assertFreeSpace(cfg.work_dir, MIN_FREE_BYTES);
|
|
27
|
+
fs.mkdirSync(cfg.work_dir, { recursive: true });
|
|
28
|
+
const id = opts?.id ?? defaultId(opts?.now);
|
|
29
|
+
const filePath = path.join(cfg.work_dir, `db-${id}.pgdump`);
|
|
30
|
+
opts?.onProgress?.(`Dumping ${cfg.source_db} -> ${filePath}`);
|
|
31
|
+
await runCmd('pg_dump', ['-Fc', cfg.source_db, '-f', filePath], { onProgress: opts?.onProgress });
|
|
32
|
+
const bytes = fs.statSync(filePath).size;
|
|
33
|
+
return { id, path: filePath, bytes };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Drop, recreate, and pg_restore the configured target_db from dumpPath.
|
|
37
|
+
* Guarded by assertConfirmed + assertNotProd — never runs unconfirmed or
|
|
38
|
+
* against a target that looks like production.
|
|
39
|
+
*/
|
|
40
|
+
export async function uploadDb(cfg, dumpPath, opts) {
|
|
41
|
+
assertConfirmed(opts.confirm, cfg.target_db);
|
|
42
|
+
assertNotProd(cfg.target_db, cfg.prodDbNames, opts.allowProd);
|
|
43
|
+
if (cfg.pre_restore_cmd) {
|
|
44
|
+
opts.onProgress?.('Running pre_restore_cmd...');
|
|
45
|
+
await runShell(cfg.pre_restore_cmd, { onProgress: opts.onProgress });
|
|
46
|
+
}
|
|
47
|
+
opts.onProgress?.(`Dropping ${cfg.target_db}...`);
|
|
48
|
+
await runCmd('dropdb', ['--if-exists', cfg.target_db], { onProgress: opts.onProgress });
|
|
49
|
+
opts.onProgress?.(`Creating ${cfg.target_db}...`);
|
|
50
|
+
await runCmd('createdb', ['-O', cfg.owner, cfg.target_db], { onProgress: opts.onProgress });
|
|
51
|
+
opts.onProgress?.(`Restoring ${dumpPath} -> ${cfg.target_db}...`);
|
|
52
|
+
await runCmd('pg_restore', ['-d', cfg.target_db, dumpPath], { onProgress: opts.onProgress });
|
|
53
|
+
if (cfg.post_restore_cmd) {
|
|
54
|
+
opts.onProgress?.('Running post_restore_cmd...');
|
|
55
|
+
await runShell(cfg.post_restore_cmd, { onProgress: opts.onProgress });
|
|
56
|
+
}
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* tar cfg.files_root into <work_dir>/files-<id>.tar.gz, plus the current
|
|
61
|
+
* .env.local (from process.cwd()) added under the fixed member name
|
|
62
|
+
* __env.local.
|
|
63
|
+
*
|
|
64
|
+
* Approach: stage a temp dir containing ONLY __env.local, then run a single
|
|
65
|
+
* `tar -czf out -C files_root . -C stageDir __env.local` invocation — tar
|
|
66
|
+
* accepts multiple -C flags, each retargeting the base dir for the file
|
|
67
|
+
* args that follow it, so one archive gets both "everything in files_root"
|
|
68
|
+
* and "one file from elsewhere" without a second tar --append pass (which
|
|
69
|
+
* would mean rewriting/recompressing the whole gzip stream).
|
|
70
|
+
*/
|
|
71
|
+
export async function downloadFiles(cfg, opts) {
|
|
72
|
+
assertFreeSpace(cfg.work_dir, MIN_FREE_BYTES);
|
|
73
|
+
fs.mkdirSync(cfg.work_dir, { recursive: true });
|
|
74
|
+
fs.mkdirSync(cfg.files_root, { recursive: true });
|
|
75
|
+
const id = opts?.id ?? defaultId(opts?.now);
|
|
76
|
+
const filePath = path.join(cfg.work_dir, `files-${id}.tar.gz`);
|
|
77
|
+
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hazo_envsync_stage_'));
|
|
78
|
+
try {
|
|
79
|
+
const envLocalSrc = path.join(process.cwd(), '.env.local');
|
|
80
|
+
const stagedEnvLocal = path.join(stageDir, ENV_LOCAL_MEMBER);
|
|
81
|
+
fs.writeFileSync(stagedEnvLocal, fs.existsSync(envLocalSrc) ? fs.readFileSync(envLocalSrc) : '');
|
|
82
|
+
opts?.onProgress?.(`Archiving ${cfg.files_root} -> ${filePath}`);
|
|
83
|
+
await runCmd('tar', ['-czf', filePath, '-C', cfg.files_root, '.', '-C', stageDir, ENV_LOCAL_MEMBER], { onProgress: opts?.onProgress });
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
const bytes = fs.statSync(filePath).size;
|
|
89
|
+
return { id, path: filePath, bytes };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Preview or apply an archive from downloadFiles into cfg.files_root +
|
|
93
|
+
* process.cwd()/.env.local.
|
|
94
|
+
*
|
|
95
|
+
* Always: extract __env.local from the archive, merge cfg.envOverrides on
|
|
96
|
+
* top (overrides win), and diff the merged result against the CURRENT
|
|
97
|
+
* target .env.local — i.e. "what's about to change" (see dotenv-merge.ts
|
|
98
|
+
* for why that's the more useful diff direction than "vs the archive's
|
|
99
|
+
* original values").
|
|
100
|
+
*
|
|
101
|
+
* If opts.confirm is falsy: returns { ok: false, diff } — a dry-run
|
|
102
|
+
* preview, no writes at all.
|
|
103
|
+
*
|
|
104
|
+
* If opts.confirm is truthy: guarded by assertNotProd against
|
|
105
|
+
* cfg.target_db (reusing the same DB-name guard uploadDb uses — files and
|
|
106
|
+
* DB uploads are two halves of syncing INTO the same target environment,
|
|
107
|
+
* so it's the meaningful "is this prod" check available here), then backs
|
|
108
|
+
* up the existing target .env.local, wipes and repopulates files_root from
|
|
109
|
+
* the archive, and writes the merged .env.local.
|
|
110
|
+
*/
|
|
111
|
+
export async function uploadFiles(cfg, archivePath, opts) {
|
|
112
|
+
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hazo_envsync_extract_'));
|
|
113
|
+
let archiveEnvText = '';
|
|
114
|
+
try {
|
|
115
|
+
await runCmd('tar', ['-xzf', archivePath, '-C', extractDir, ENV_LOCAL_MEMBER], {
|
|
116
|
+
onProgress: opts.onProgress,
|
|
117
|
+
});
|
|
118
|
+
const extractedEnvLocal = path.join(extractDir, ENV_LOCAL_MEMBER);
|
|
119
|
+
if (fs.existsSync(extractedEnvLocal)) {
|
|
120
|
+
archiveEnvText = fs.readFileSync(extractedEnvLocal, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
const archiveEnv = parseDotenvText(archiveEnvText);
|
|
127
|
+
const merged = mergeEnvOverrides(archiveEnv, cfg.envOverrides);
|
|
128
|
+
const targetEnvLocalPath = path.join(process.cwd(), '.env.local');
|
|
129
|
+
const currentEnvText = fs.existsSync(targetEnvLocalPath)
|
|
130
|
+
? fs.readFileSync(targetEnvLocalPath, 'utf8')
|
|
131
|
+
: '';
|
|
132
|
+
const currentEnv = parseDotenvText(currentEnvText);
|
|
133
|
+
const diff = diffEnvAgainstCurrent(merged, currentEnv);
|
|
134
|
+
if (!opts.confirm) {
|
|
135
|
+
return { ok: false, diff };
|
|
136
|
+
}
|
|
137
|
+
assertNotProd(cfg.target_db, cfg.prodDbNames, opts.allowProd);
|
|
138
|
+
if (fs.existsSync(targetEnvLocalPath)) {
|
|
139
|
+
const ts = Date.now();
|
|
140
|
+
fs.copyFileSync(targetEnvLocalPath, `${targetEnvLocalPath}.bak-${ts}`);
|
|
141
|
+
}
|
|
142
|
+
opts.onProgress?.(`Clearing ${cfg.files_root}...`);
|
|
143
|
+
fs.rmSync(cfg.files_root, { recursive: true, force: true });
|
|
144
|
+
fs.mkdirSync(cfg.files_root, { recursive: true });
|
|
145
|
+
opts.onProgress?.(`Extracting ${archivePath} -> ${cfg.files_root}...`);
|
|
146
|
+
await runCmd('tar', ['-xzf', archivePath, '-C', cfg.files_root], { onProgress: opts.onProgress });
|
|
147
|
+
// __env.local extracts as a real file inside files_root — it isn't a real
|
|
148
|
+
// asset, so remove it rather than trying to get tar to exclude it during
|
|
149
|
+
// extraction (simplest correct approach per spec).
|
|
150
|
+
const extractedEnvMember = path.join(cfg.files_root, ENV_LOCAL_MEMBER);
|
|
151
|
+
if (fs.existsSync(extractedEnvMember))
|
|
152
|
+
fs.rmSync(extractedEnvMember, { force: true });
|
|
153
|
+
fs.writeFileSync(targetEnvLocalPath, serializeDotenv(merged));
|
|
154
|
+
return { ok: true, diff };
|
|
155
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ExecOptions {
|
|
2
|
+
onProgress?: (line: string) => void;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Run `cmd` with `args` as a real argv array — never a shell string, so no
|
|
6
|
+
* config value can smuggle shell metacharacters into the command line.
|
|
7
|
+
* Streams stdout/stderr lines to opts.onProgress. Resolves on exit 0,
|
|
8
|
+
* rejects on non-zero exit or a spawn error (e.g. ENOENT).
|
|
9
|
+
*/
|
|
10
|
+
export declare function runCmd(cmd: string, args: string[], opts?: ExecOptions): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Shell-string execution — the ONE exception to "always argv arrays".
|
|
13
|
+
* Reserved exclusively for the two operator-supplied full command strings
|
|
14
|
+
* (EnvsyncConfig.pre_restore_cmd / post_restore_cmd). Those come from the
|
|
15
|
+
* INI file the operator controls (not end-user input), the same trust
|
|
16
|
+
* level as backup.ts's pre_restore_cmd/post_restore_cmd which already run
|
|
17
|
+
* as raw bash on the remote in migrate/db-dump-restore.ts. Do not call
|
|
18
|
+
* this with any value that isn't operator config.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runShell(command: string, opts?: ExecOptions): Promise<void>;
|
|
21
|
+
//# sourceMappingURL=exec.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../../src/envsync/exec.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AASD;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBrF;AAED;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB3E"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// hazo_env/src/envsync/exec.ts — local command execution for envsync
|
|
2
|
+
//
|
|
3
|
+
// Simpler cousin of migrate/ssh-exec.ts::runSsh: no SSH, no known_hosts,
|
|
4
|
+
// just spawn(cmd, args) against the local machine. envsync only ever talks
|
|
5
|
+
// to local Postgres and local paths — operators move dump/archive files
|
|
6
|
+
// between hosts manually, outside this code.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
function streamLines(chunk, onProgress) {
|
|
9
|
+
if (!onProgress)
|
|
10
|
+
return;
|
|
11
|
+
for (const line of chunk.toString().split('\n')) {
|
|
12
|
+
if (line.trim())
|
|
13
|
+
onProgress(line);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run `cmd` with `args` as a real argv array — never a shell string, so no
|
|
18
|
+
* config value can smuggle shell metacharacters into the command line.
|
|
19
|
+
* Streams stdout/stderr lines to opts.onProgress. Resolves on exit 0,
|
|
20
|
+
* rejects on non-zero exit or a spawn error (e.g. ENOENT).
|
|
21
|
+
*/
|
|
22
|
+
export function runCmd(cmd, args, opts) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const proc = spawn(cmd, args);
|
|
25
|
+
proc.stdout.on('data', (chunk) => streamLines(chunk, opts?.onProgress));
|
|
26
|
+
proc.stderr.on('data', (chunk) => streamLines(chunk, opts?.onProgress));
|
|
27
|
+
proc.on('error', (err) => reject(err));
|
|
28
|
+
proc.on('close', (code) => {
|
|
29
|
+
if (code !== 0) {
|
|
30
|
+
reject(new Error(`${cmd} exited with code ${code}`));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
resolve();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Shell-string execution — the ONE exception to "always argv arrays".
|
|
40
|
+
* Reserved exclusively for the two operator-supplied full command strings
|
|
41
|
+
* (EnvsyncConfig.pre_restore_cmd / post_restore_cmd). Those come from the
|
|
42
|
+
* INI file the operator controls (not end-user input), the same trust
|
|
43
|
+
* level as backup.ts's pre_restore_cmd/post_restore_cmd which already run
|
|
44
|
+
* as raw bash on the remote in migrate/db-dump-restore.ts. Do not call
|
|
45
|
+
* this with any value that isn't operator config.
|
|
46
|
+
*/
|
|
47
|
+
export function runShell(command, opts) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const proc = spawn(command, { shell: true });
|
|
50
|
+
proc.stdout.on('data', (chunk) => streamLines(chunk, opts?.onProgress));
|
|
51
|
+
proc.stderr.on('data', (chunk) => streamLines(chunk, opts?.onProgress));
|
|
52
|
+
proc.on('error', (err) => reject(err));
|
|
53
|
+
proc.on('close', (code) => {
|
|
54
|
+
if (code !== 0) {
|
|
55
|
+
reject(new Error(`shell command exited with code ${code}`));
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
resolve();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throw unless confirm is explicitly true. Used to gate destructive ops
|
|
3
|
+
* (dropdb/createdb/pg_restore, wiping files_root) behind an explicit
|
|
4
|
+
* caller opt-in rather than running on a bare invocation.
|
|
5
|
+
*/
|
|
6
|
+
export declare function assertConfirmed(confirm: boolean | undefined, targetName: string): void;
|
|
7
|
+
/**
|
|
8
|
+
* Throw if targetName looks like a production target (is in prodNames) and
|
|
9
|
+
* the caller hasn't explicitly opted in via allowProd.
|
|
10
|
+
*/
|
|
11
|
+
export declare function assertNotProd(targetName: string, prodNames: string[], allowProd: boolean | undefined): void;
|
|
12
|
+
//# sourceMappingURL=guards.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../src/envsync/guards.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAMtF;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,OAAO,GAAG,SAAS,GAAG,IAAI,CAM3G"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// hazo_env/src/envsync/guards.ts — destructive-op safety helpers for envsync
|
|
2
|
+
/**
|
|
3
|
+
* Throw unless confirm is explicitly true. Used to gate destructive ops
|
|
4
|
+
* (dropdb/createdb/pg_restore, wiping files_root) behind an explicit
|
|
5
|
+
* caller opt-in rather than running on a bare invocation.
|
|
6
|
+
*/
|
|
7
|
+
export function assertConfirmed(confirm, targetName) {
|
|
8
|
+
if (!confirm) {
|
|
9
|
+
throw new Error(`Refusing to upload_db into "${targetName}" without confirm=true — pass confirm to proceed.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Throw if targetName looks like a production target (is in prodNames) and
|
|
14
|
+
* the caller hasn't explicitly opted in via allowProd.
|
|
15
|
+
*/
|
|
16
|
+
export function assertNotProd(targetName, prodNames, allowProd) {
|
|
17
|
+
if (prodNames.includes(targetName) && !allowProd) {
|
|
18
|
+
throw new Error(`Refusing: "${targetName}" looks like a production target. Pass allowProd:true to override.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class EnvsyncLockError extends Error {
|
|
2
|
+
code: string;
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Run fn() while holding a single-run lock for workDir. Throws
|
|
7
|
+
* EnvsyncLockError (code 'ENVSYNC_LOCKED') if a lock is already held,
|
|
8
|
+
* either in-process or on disk. Always releases the lock in a finally,
|
|
9
|
+
* even if fn() throws.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withLock<T>(workDir: string, op: string, fn: () => Promise<T>): Promise<T>;
|
|
12
|
+
//# sourceMappingURL=lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/envsync/lock.ts"],"names":[],"mappings":"AAWA,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,IAAI,SAAoB;gBACZ,OAAO,EAAE,MAAM;CAI5B;AAqDD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAyB/F"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// hazo_env/src/envsync/lock.ts — single-run guard for envsync operations
|
|
2
|
+
//
|
|
3
|
+
// Combines an in-process mutex (so two calls within the same Node process
|
|
4
|
+
// can't overlap) with an on-disk lockfile inside work_dir (so a second CLI
|
|
5
|
+
// invocation, or a second process, can't overlap either). The on-disk lock
|
|
6
|
+
// self-heals: if the PID recorded in the lockfile is no longer alive (e.g.
|
|
7
|
+
// the previous run crashed), the stale lock is cleared automatically.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
export class EnvsyncLockError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = 'ENVSYNC_LOCKED';
|
|
14
|
+
this.name = 'EnvsyncLockError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// In-process mutex, keyed by work_dir so unrelated envsync configs (e.g. in
|
|
18
|
+
// tests using different temp dirs) never contend with each other.
|
|
19
|
+
const inProcessLocks = new Set();
|
|
20
|
+
function lockFilePath(workDir) {
|
|
21
|
+
return path.join(workDir, '.envsync.lock');
|
|
22
|
+
}
|
|
23
|
+
function isPidAlive(pid) {
|
|
24
|
+
try {
|
|
25
|
+
// Signal 0 performs no-op existence/permission check without killing anything.
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Inspect (and clear, if stale) any on-disk lockfile in workDir.
|
|
35
|
+
* Throws EnvsyncLockError if a live lock is held by another process.
|
|
36
|
+
*/
|
|
37
|
+
function checkOnDiskLock(workDir, op) {
|
|
38
|
+
const lockPath = lockFilePath(workDir);
|
|
39
|
+
if (!fs.existsSync(lockPath))
|
|
40
|
+
return;
|
|
41
|
+
let data = null;
|
|
42
|
+
try {
|
|
43
|
+
data = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Corrupt/unreadable lockfile — treat as stale and clear it.
|
|
47
|
+
fs.rmSync(lockPath, { force: true });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (data && typeof data.pid === 'number' && isPidAlive(data.pid)) {
|
|
51
|
+
throw new EnvsyncLockError(`envsync: another operation ("${data.op}", pid ${data.pid}) is already in progress in ${workDir} — refusing to start "${op}"`);
|
|
52
|
+
}
|
|
53
|
+
// Stale lock from a dead process — auto-clear.
|
|
54
|
+
fs.rmSync(lockPath, { force: true });
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Run fn() while holding a single-run lock for workDir. Throws
|
|
58
|
+
* EnvsyncLockError (code 'ENVSYNC_LOCKED') if a lock is already held,
|
|
59
|
+
* either in-process or on disk. Always releases the lock in a finally,
|
|
60
|
+
* even if fn() throws.
|
|
61
|
+
*/
|
|
62
|
+
export async function withLock(workDir, op, fn) {
|
|
63
|
+
if (inProcessLocks.has(workDir)) {
|
|
64
|
+
throw new EnvsyncLockError(`envsync: another operation is already in progress in this process for ${workDir} — refusing to start "${op}"`);
|
|
65
|
+
}
|
|
66
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
67
|
+
checkOnDiskLock(workDir, op);
|
|
68
|
+
inProcessLocks.add(workDir);
|
|
69
|
+
const lockPath = lockFilePath(workDir);
|
|
70
|
+
const data = { pid: process.pid, timestamp: Date.now(), op };
|
|
71
|
+
fs.writeFileSync(lockPath, JSON.stringify(data));
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
inProcessLocks.delete(workDir);
|
|
77
|
+
try {
|
|
78
|
+
fs.rmSync(lockPath, { force: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// best-effort cleanup
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete old envsync files in workDir, keeping the newest `opts.keep` per
|
|
3
|
+
* pattern (db-*.pgdump and files-*.tar.gz are pruned as separate groups).
|
|
4
|
+
* "Newest" is by mtime. Returns the names of files that were deleted.
|
|
5
|
+
*/
|
|
6
|
+
export declare function pruneWorkDir(workDir: string, opts: {
|
|
7
|
+
keep: number;
|
|
8
|
+
}): {
|
|
9
|
+
deleted: string[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Throw if workDir's filesystem has less than minBytes available.
|
|
13
|
+
* Uses fs.statfsSync (Node 18.15+/20+) to read available blocks directly —
|
|
14
|
+
* no shelling out to `df`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function assertFreeSpace(workDir: string, minBytes: number): void;
|
|
17
|
+
//# sourceMappingURL=retention.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../../src/envsync/retention.ts"],"names":[],"mappings":"AAgBA;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAuB3F;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CASvE"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// hazo_env/src/envsync/retention.ts — work_dir housekeeping for envsync
|
|
2
|
+
//
|
|
3
|
+
// Prunes old dump/archive files so work_dir doesn't grow without bound, and
|
|
4
|
+
// checks free disk space before a new dump/archive is created.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
// The two file kinds envsync ever writes into work_dir (see engine.ts).
|
|
8
|
+
// Each kind is pruned independently, keeping the newest N of *each* kind —
|
|
9
|
+
// otherwise a single "keep 3" would let one kind starve the other.
|
|
10
|
+
const RETAINED_PATTERNS = [
|
|
11
|
+
/^db-.+\.pgdump$/,
|
|
12
|
+
/^files-.+\.tar\.gz$/,
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Delete old envsync files in workDir, keeping the newest `opts.keep` per
|
|
16
|
+
* pattern (db-*.pgdump and files-*.tar.gz are pruned as separate groups).
|
|
17
|
+
* "Newest" is by mtime. Returns the names of files that were deleted.
|
|
18
|
+
*/
|
|
19
|
+
export function pruneWorkDir(workDir, opts) {
|
|
20
|
+
const deleted = [];
|
|
21
|
+
if (!fs.existsSync(workDir))
|
|
22
|
+
return { deleted };
|
|
23
|
+
const entries = fs.readdirSync(workDir);
|
|
24
|
+
for (const pattern of RETAINED_PATTERNS) {
|
|
25
|
+
const group = entries
|
|
26
|
+
.filter((name) => pattern.test(name))
|
|
27
|
+
.map((name) => {
|
|
28
|
+
const full = path.join(workDir, name);
|
|
29
|
+
const mtime = fs.statSync(full).mtimeMs;
|
|
30
|
+
return { name, full, mtime };
|
|
31
|
+
})
|
|
32
|
+
.sort((a, b) => b.mtime - a.mtime); // newest first
|
|
33
|
+
for (const stale of group.slice(Math.max(opts.keep, 0))) {
|
|
34
|
+
fs.rmSync(stale.full, { force: true });
|
|
35
|
+
deleted.push(stale.name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { deleted };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Throw if workDir's filesystem has less than minBytes available.
|
|
42
|
+
* Uses fs.statfsSync (Node 18.15+/20+) to read available blocks directly —
|
|
43
|
+
* no shelling out to `df`.
|
|
44
|
+
*/
|
|
45
|
+
export function assertFreeSpace(workDir, minBytes) {
|
|
46
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
47
|
+
const stat = fs.statfsSync(workDir);
|
|
48
|
+
const availableBytes = stat.bavail * stat.bsize;
|
|
49
|
+
if (availableBytes < minBytes) {
|
|
50
|
+
throw new Error(`envsync: insufficient free space in ${workDir} — ${availableBytes} bytes available, ${minBytes} required`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
export interface StartEnvsyncServiceOptions {
|
|
3
|
+
port?: number;
|
|
4
|
+
bind?: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface EnvsyncServiceHandle {
|
|
8
|
+
server: http.Server;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Start the envsync HTTP service. Throws synchronously if no bearer token
|
|
13
|
+
* is configured (via opts.token or HAZO_ENVSYNC_TOKEN) — this service must
|
|
14
|
+
* never run unauthenticated.
|
|
15
|
+
*
|
|
16
|
+
* opts override env vars, which makes this testable without real env vars
|
|
17
|
+
* or real ports: pass { port: 0 } for an OS-assigned ephemeral port and read
|
|
18
|
+
* the actual port off `server.address()`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function startEnvsyncService(opts?: StartEnvsyncServiceOptions): EnvsyncServiceHandle;
|
|
21
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/envsync/service.ts"],"names":[],"mappings":"AA4BA,OAAO,IAAI,MAAM,WAAW,CAAC;AAgB7B,MAAM,WAAW,0BAA0B;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IACpB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AA4VD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,0BAA+B,GAAG,oBAAoB,CA8B/F"}
|