@eliware/vyops 1.0.5 → 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 +17 -0
- package/package.json +3 -3
- package/src/args.mjs +3 -2
- package/src/backup.mjs +29 -0
- package/src/deploy.mjs +10 -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,22 @@
|
|
|
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
|
+
|
|
3
20
|
## 1.0.5
|
|
4
21
|
|
|
5
22
|
- Create nested temporary directories before uploading synchronized 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
|
@@ -82,6 +82,8 @@ export async function deploy({ target, config }) {
|
|
|
82
82
|
const client = await connect(target);
|
|
83
83
|
debugLog('SSH connected');
|
|
84
84
|
let finalizeHooks;
|
|
85
|
+
let hooksFinalized = false;
|
|
86
|
+
let deploymentCommitted = false;
|
|
85
87
|
const runId = randomUUID();
|
|
86
88
|
const remote = `/home/vyos/.config.deploy.${runId}`;
|
|
87
89
|
try {
|
|
@@ -92,25 +94,29 @@ export async function deploy({ target, config }) {
|
|
|
92
94
|
debugLog('starting interactive deployment sequence');
|
|
93
95
|
const output = await interactive(client, [
|
|
94
96
|
'configure',
|
|
95
|
-
{ 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 },
|
|
96
98
|
"printf '%s\\n' '--- compare ---'",
|
|
97
99
|
'compare',
|
|
98
100
|
"printf '%s\\n' '--- end compare ---'",
|
|
99
|
-
{ 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 },
|
|
100
102
|
{ command: 'confirm', reject: /(?:confirm failed|error|invalid)/i },
|
|
101
103
|
{ command: 'save', reject: /(?:save failed|error|invalid)/i },
|
|
102
104
|
'exit',
|
|
103
105
|
'exit',
|
|
104
106
|
], debugLog);
|
|
105
107
|
debugLog(`interactive sequence returned (${output.length} bytes)`);
|
|
108
|
+
deploymentCommitted = true;
|
|
106
109
|
const compare = extractCompare(output);
|
|
107
110
|
if (compare) log.info(compare);
|
|
108
|
-
if (finalizeHooks) await finalizeHooks(true);
|
|
109
111
|
debugLog(`syncing live config: /config/config.boot -> ${config}`);
|
|
110
112
|
await download(client, '/config/config.boot', config);
|
|
113
|
+
if (finalizeHooks) {
|
|
114
|
+
hooksFinalized = true;
|
|
115
|
+
await finalizeHooks(true);
|
|
116
|
+
}
|
|
111
117
|
return 0;
|
|
112
118
|
} catch (error) {
|
|
113
|
-
if (finalizeHooks) await finalizeHooks(false);
|
|
119
|
+
if (finalizeHooks && !hooksFinalized && !deploymentCommitted) await finalizeHooks(false);
|
|
114
120
|
throw error;
|
|
115
121
|
} finally {
|
|
116
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);
|