@eliware/vyops 1.0.4 → 1.0.6
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/README.md +6 -1
- package/RELEASE_NOTES.md +29 -0
- package/package.json +3 -3
- package/src/args.mjs +3 -2
- package/src/backup.mjs +29 -0
- package/src/deploy.mjs +15 -4
- package/src/git.mjs +1 -1
- package/src/main.mjs +6 -0
- package/src/ssh.mjs +7 -7
package/README.md
CHANGED
|
@@ -55,8 +55,13 @@ Console switches:
|
|
|
55
55
|
./vyops.mjs --version
|
|
56
56
|
./vyops.mjs --dry-run vyos@core1 /path/to/config.boot
|
|
57
57
|
./vyops.mjs --force --debug vyos@core1 /path/to/config.boot
|
|
58
|
+
./vyops.mjs --backup vyos@core1 /path/to/backup
|
|
58
59
|
```
|
|
59
60
|
|
|
61
|
+
`--backup` downloads the active `/config/config.boot` and the complete
|
|
62
|
+
`/config/scripts` tree into the destination directory. It does not enter
|
|
63
|
+
configuration mode or modify the router.
|
|
64
|
+
|
|
60
65
|
`--dry-run` validates the config and skips SSH, commit, save, download, and Git pushback.
|
|
61
66
|
|
|
62
67
|
The deployment workflow:
|
|
@@ -105,7 +110,7 @@ Deployments require existing SSH keys and a reachable VyOS target. The workflow
|
|
|
105
110
|
|
|
106
111
|
## Support
|
|
107
112
|
|
|
108
|
-
Repository: `git@github.com:eliware/vyops
|
|
113
|
+
Repository: `git@github.com:eliware/vyops.git`
|
|
109
114
|
|
|
110
115
|
Open an issue or provide a focused patch with tests and validation results.
|
|
111
116
|
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## 1.0.6
|
|
4
|
+
|
|
5
|
+
- Add `--backup` to download the active `config.boot` and complete
|
|
6
|
+
`/config/scripts` tree into a local backup directory.
|
|
7
|
+
- Fail promptly on VyOS load and commit errors instead of waiting indefinitely
|
|
8
|
+
for confirmation prompts.
|
|
9
|
+
- Preserve synchronized scripts when post-commit local config download fails.
|
|
10
|
+
- Include staged configuration changes in Git pushback detection.
|
|
11
|
+
- Expand CLI, deployment, backup, Git, and SSH regression coverage.
|
|
12
|
+
|
|
13
|
+
Verification:
|
|
14
|
+
|
|
15
|
+
- `npm test` (100% coverage across statements, branches, functions, and lines)
|
|
16
|
+
- `npm run lint`
|
|
17
|
+
- `npm run audit`
|
|
18
|
+
- `npm run validate:package`
|
|
19
|
+
|
|
20
|
+
## 1.0.5
|
|
21
|
+
|
|
22
|
+
- Create nested temporary directories before uploading synchronized scripts.
|
|
23
|
+
- Fix deployment of nested files such as systemd units and boot scripts.
|
|
24
|
+
|
|
25
|
+
Verification:
|
|
26
|
+
|
|
27
|
+
- `npm test`
|
|
28
|
+
- `npm run lint`
|
|
29
|
+
- `npm run audit`
|
|
30
|
+
- `npm run validate:package`
|
|
31
|
+
|
|
3
32
|
## 1.0.4
|
|
4
33
|
|
|
5
34
|
- Synchronize the complete repository `scripts/` tree to `/config/scripts`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eliware/vyops",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Deploy native VyOS configuration files over SSH.",
|
|
5
5
|
"main": "vyops.mjs",
|
|
6
6
|
"scripts": {
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
],
|
|
22
22
|
"type": "module",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@eliware/common": "^1.1.
|
|
24
|
+
"@eliware/common": "^1.1.8",
|
|
25
25
|
"ssh2": "^1.17.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"jest": "^30.4.2",
|
|
29
|
-
"oxlint": "^1.
|
|
29
|
+
"oxlint": "^1.79.0"
|
|
30
30
|
},
|
|
31
31
|
"bin": {
|
|
32
32
|
"vyops": "./bin/vyops"
|
package/src/args.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const usage = `Usage:
|
|
2
2
|
vyops [--dry-run] [--force] [--debug] <user@hostname-or-ip> <config.boot>
|
|
3
|
+
vyops --backup [--debug] <user@hostname-or-ip> <destination-directory>
|
|
3
4
|
vyops --help
|
|
4
5
|
vyops --version`;
|
|
5
6
|
|
|
@@ -8,8 +9,8 @@ export function parseArgs(argv) {
|
|
|
8
9
|
if (argv.length === 1 && argv[0] === '--version') return { version: true };
|
|
9
10
|
|
|
10
11
|
const options = new Set(argv.filter(value => value.startsWith('--')));
|
|
11
|
-
if ([...options].some(option => !['--dry-run', '--force', '--debug'].includes(option))) throw new Error(usage);
|
|
12
|
+
if ([...options].some(option => !['--dry-run', '--force', '--debug', '--backup'].includes(option))) throw new Error(usage);
|
|
12
13
|
const values = argv.filter(value => !value.startsWith('--'));
|
|
13
14
|
if (values.length !== 2) throw new Error(usage);
|
|
14
|
-
return { target: values[0], config: values[1], ...(options.has('--dry-run') ? { dryRun: true } : {}), ...(options.has('--force') ? { force: true } : {}), ...(options.has('--debug') ? { debug: true } : {}) };
|
|
15
|
+
return { target: values[0], config: values[1], ...(options.has('--backup') ? { backup: true } : {}), ...(options.has('--dry-run') ? { dryRun: true } : {}), ...(options.has('--force') ? { force: true } : {}), ...(options.has('--debug') ? { debug: true } : {}) };
|
|
15
16
|
}
|
package/src/backup.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fs, path, log } from '@eliware/common';
|
|
2
|
+
import { close, connect, download, exec } from './ssh.mjs';
|
|
3
|
+
|
|
4
|
+
function remoteScriptPath(value) {
|
|
5
|
+
const name = value.replace(/^\/config\/scripts\/?/, '');
|
|
6
|
+
if (!name || name.startsWith('/') || name.split('/').includes('..')) throw new Error(`unsafe remote script path: ${value}`);
|
|
7
|
+
return name;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function backup({ target, config }) {
|
|
11
|
+
const client = await connect(target);
|
|
12
|
+
try {
|
|
13
|
+
await fs.promises.mkdir(config, { recursive: true });
|
|
14
|
+
await fs.promises.mkdir(path(config, 'scripts'), { recursive: true });
|
|
15
|
+
await download(client, '/config/config.boot', path(config, 'config.boot'));
|
|
16
|
+
const result = await exec(client, "find /config/scripts -type f -print 2>/dev/null");
|
|
17
|
+
if (result.code !== 0) throw new Error(`could not list remote scripts: ${result.stderr || result.stdout}`.trim());
|
|
18
|
+
const files = result.stdout.split(/\r?\n/).map(value => value.trim()).filter(Boolean).map(remoteScriptPath);
|
|
19
|
+
for (const name of files) {
|
|
20
|
+
const local = path(config, 'scripts', name);
|
|
21
|
+
await fs.promises.mkdir(path(local, '..'), { recursive: true });
|
|
22
|
+
await download(client, `/config/scripts/${name}`, local);
|
|
23
|
+
log.debug(`[vyops] backed up script: ${name}`);
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
} finally {
|
|
27
|
+
await close(client);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/deploy.mjs
CHANGED
|
@@ -53,6 +53,11 @@ async function installScripts(client, config, log, runId) {
|
|
|
53
53
|
const mode = (await fs.promises.stat(local)).mode & 0o777;
|
|
54
54
|
const backedUp = await exec(client, `if sudo test -e ${JSON.stringify(installed)}; then sudo mkdir -p ${JSON.stringify(`${backupDir}/${parent}`)} && sudo cp -p -- ${JSON.stringify(installed)} ${JSON.stringify(backup)}; fi`);
|
|
55
55
|
if (backedUp.code !== 0) throw new Error(`script backup failed (${name}): ${backedUp.stderr || backedUp.stdout}`.trim());
|
|
56
|
+
if (parent !== '.') {
|
|
57
|
+
const remoteParent = `${remoteDir}/${parent}`;
|
|
58
|
+
const remoteParentResult = await exec(client, `mkdir -p ${JSON.stringify(remoteParent)}`);
|
|
59
|
+
if (remoteParentResult.code !== 0) throw new Error(`script upload directory setup failed (${name}): ${remoteParentResult.stderr || remoteParentResult.stdout}`.trim());
|
|
60
|
+
}
|
|
56
61
|
await upload(client, local, remote);
|
|
57
62
|
const installedResult = await exec(client, `sudo mkdir -p ${JSON.stringify(`${installDir}/${parent}`)} && sudo install -m ${mode.toString(8)} ${JSON.stringify(remote)} ${JSON.stringify(installed)}`);
|
|
58
63
|
if (installedResult.code !== 0) throw new Error(`script install failed (${name}): ${installedResult.stderr || installedResult.stdout}`.trim());
|
|
@@ -77,6 +82,8 @@ export async function deploy({ target, config }) {
|
|
|
77
82
|
const client = await connect(target);
|
|
78
83
|
debugLog('SSH connected');
|
|
79
84
|
let finalizeHooks;
|
|
85
|
+
let hooksFinalized = false;
|
|
86
|
+
let deploymentCommitted = false;
|
|
80
87
|
const runId = randomUUID();
|
|
81
88
|
const remote = `/home/vyos/.config.deploy.${runId}`;
|
|
82
89
|
try {
|
|
@@ -87,25 +94,29 @@ export async function deploy({ target, config }) {
|
|
|
87
94
|
debugLog('starting interactive deployment sequence');
|
|
88
95
|
const output = await interactive(client, [
|
|
89
96
|
'configure',
|
|
90
|
-
{ command: `load ${remote}`, reject: /(?:load failed|error|invalid)/i },
|
|
97
|
+
{ command: `load ${remote}`, reject: /(?:load failed|commit failed|commit aborted|cannot commit|configuration (?:commit )?failed|invalid configuration|error|invalid)/i },
|
|
91
98
|
"printf '%s\\n' '--- compare ---'",
|
|
92
99
|
'compare',
|
|
93
100
|
"printf '%s\\n' '--- end compare ---'",
|
|
94
|
-
{ command: 'commit-confirm 5', reject: /(?:commit failed|error|invalid)/i },
|
|
101
|
+
{ command: 'commit-confirm 5', reject: /(?:commit failed|commit aborted|cannot commit|configuration (?:commit )?failed|invalid configuration|error|invalid)/i },
|
|
95
102
|
{ command: 'confirm', reject: /(?:confirm failed|error|invalid)/i },
|
|
96
103
|
{ command: 'save', reject: /(?:save failed|error|invalid)/i },
|
|
97
104
|
'exit',
|
|
98
105
|
'exit',
|
|
99
106
|
], debugLog);
|
|
100
107
|
debugLog(`interactive sequence returned (${output.length} bytes)`);
|
|
108
|
+
deploymentCommitted = true;
|
|
101
109
|
const compare = extractCompare(output);
|
|
102
110
|
if (compare) log.info(compare);
|
|
103
|
-
if (finalizeHooks) await finalizeHooks(true);
|
|
104
111
|
debugLog(`syncing live config: /config/config.boot -> ${config}`);
|
|
105
112
|
await download(client, '/config/config.boot', config);
|
|
113
|
+
if (finalizeHooks) {
|
|
114
|
+
hooksFinalized = true;
|
|
115
|
+
await finalizeHooks(true);
|
|
116
|
+
}
|
|
106
117
|
return 0;
|
|
107
118
|
} catch (error) {
|
|
108
|
-
if (finalizeHooks) await finalizeHooks(false);
|
|
119
|
+
if (finalizeHooks && !hooksFinalized && !deploymentCommitted) await finalizeHooks(false);
|
|
109
120
|
throw error;
|
|
110
121
|
} finally {
|
|
111
122
|
debugLog('cleaning up remote file');
|
package/src/git.mjs
CHANGED
|
@@ -75,7 +75,7 @@ export async function pushBack(config, { force = false } = {}) {
|
|
|
75
75
|
const repo = root.trim();
|
|
76
76
|
return withRepositoryLock(repo, async () => {
|
|
77
77
|
const relative = config.startsWith(`${repo}/`) ? config.slice(repo.length + 1) : config;
|
|
78
|
-
const { stdout: diff } = await git(['diff', '--', relative], repo);
|
|
78
|
+
const { stdout: diff } = await git(['diff', 'HEAD', '--', relative], repo);
|
|
79
79
|
if (!diff) return false;
|
|
80
80
|
await git(['add', '--', relative], repo);
|
|
81
81
|
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
package/src/main.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseArgs, usage } from './args.mjs';
|
|
2
2
|
import packageJson from '../package.json' with { type: 'json' };
|
|
3
3
|
import { deploy } from './deploy.mjs';
|
|
4
|
+
import { backup } from './backup.mjs';
|
|
4
5
|
import { readAndValidateConfig } from './validate.mjs';
|
|
5
6
|
import { pushBack, shouldSkip } from './git.mjs';
|
|
6
7
|
import { closeAll } from './ssh.mjs';
|
|
@@ -20,6 +21,11 @@ try {
|
|
|
20
21
|
log.info(packageJson.version);
|
|
21
22
|
process.exit(0);
|
|
22
23
|
}
|
|
24
|
+
if (args.backup) {
|
|
25
|
+
await backup(args);
|
|
26
|
+
log.info(`Backup successful: ${args.config}`);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
23
29
|
log.debug(`[vyops] validating config: ${args.config}`);
|
|
24
30
|
await readAndValidateConfig(args.config);
|
|
25
31
|
log.debug('[vyops] config validation complete');
|
package/src/ssh.mjs
CHANGED
|
@@ -183,6 +183,13 @@ export function interactive(client, commands, log = () => {}) {
|
|
|
183
183
|
response += text;
|
|
184
184
|
log(`recv (${text.length} bytes): ${JSON.stringify(text)}`);
|
|
185
185
|
const cleaned = clean(response);
|
|
186
|
+
if (waiting && typeof currentItem !== 'string' && currentItem.reject?.test(cleaned)) {
|
|
187
|
+
settled = true;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
stream.close();
|
|
190
|
+
reject(new Error(`interactive command failed: ${currentItem.command}`));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
186
193
|
const pager = /(?:^|\n):\s*$/.test(cleaned) || /--More--\s*$/i.test(cleaned);
|
|
187
194
|
log(`state after data: index=${index}, waiting=${waiting}, answering=${answering}, prompt=${prompt(cleaned)}, pager=${pager}, bytes=${text.length}`);
|
|
188
195
|
if (pager) {
|
|
@@ -207,13 +214,6 @@ export function interactive(client, commands, log = () => {}) {
|
|
|
207
214
|
answering = false;
|
|
208
215
|
waiting = false;
|
|
209
216
|
log(`response [${index}]:\n${cleaned}`);
|
|
210
|
-
if (typeof currentItem !== 'string' && currentItem.reject?.test(cleaned)) {
|
|
211
|
-
settled = true;
|
|
212
|
-
clearTimeout(timer);
|
|
213
|
-
stream.close();
|
|
214
|
-
reject(new Error(`interactive command failed: ${currentItem.command}`));
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
217
|
if (index >= commands.length) {
|
|
218
218
|
settled = true;
|
|
219
219
|
clearTimeout(timer);
|