@eliware/vyops 2.0.0 → 2.1.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/README.md +20 -10
- package/RELEASE_NOTES.md +14 -0
- package/package.json +3 -3
- package/src/args.mjs +9 -7
- package/src/bundle.mjs +43 -0
- package/src/deploy.mjs +13 -2
- package/src/main.mjs +15 -6
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ npm install
|
|
|
28
28
|
The package provides:
|
|
29
29
|
|
|
30
30
|
```text
|
|
31
|
-
vyops
|
|
31
|
+
vyops Preflight or release a configuration bundle.
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Configuration
|
|
@@ -41,22 +41,29 @@ vyops Deploy or dry-run a configuration.
|
|
|
41
41
|
| `SSH_HOST_CA` | No | unset | Trusted SSH host-CA public key for certificate verification. |
|
|
42
42
|
| `LOG_LEVEL` | No | `info` | Winston log level (`error`, `warn`, `info`, `http`, `verbose`, `debug`, or `silly`). |
|
|
43
43
|
|
|
44
|
-
The
|
|
44
|
+
The config must use native VyOS curly-brace syntax. The bundle's `system host-name`
|
|
45
|
+
and single `system login user` entry provide the release target automatically.
|
|
45
46
|
|
|
46
47
|
For a router that has not received its SSH key yet, provide the SSH password through stdin:
|
|
47
48
|
|
|
48
49
|
```sh
|
|
49
|
-
printf '%s\n' "$VYOS_PASSWORD" | vyops --password-stdin
|
|
50
|
+
printf '%s\n' "$VYOS_PASSWORD" | vyops release --password-stdin /path/to/config.boot
|
|
50
51
|
```
|
|
51
52
|
|
|
52
53
|
Password mode still requires the target host key to be present in `known_hosts`; it does not disable host verification. Passwords are not accepted as command-line arguments or written to logs.
|
|
53
54
|
|
|
54
55
|
## Usage
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
Preflight a bundle without connecting:
|
|
57
58
|
|
|
58
59
|
```sh
|
|
59
|
-
vyops
|
|
60
|
+
vyops preflight /path/to/config.boot
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Release a bundle:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
vyops release /path/to/config.boot
|
|
60
67
|
```
|
|
61
68
|
|
|
62
69
|
Console switches:
|
|
@@ -64,16 +71,19 @@ Console switches:
|
|
|
64
71
|
```sh
|
|
65
72
|
vyops --help
|
|
66
73
|
vyops --version
|
|
67
|
-
vyops --
|
|
68
|
-
vyops --force --debug
|
|
69
|
-
vyops
|
|
74
|
+
vyops --debug preflight /path/to/config.boot
|
|
75
|
+
vyops release --force --debug /path/to/config.boot
|
|
76
|
+
vyops backup vyos@core1 /path/to/backup
|
|
70
77
|
```
|
|
71
78
|
|
|
72
|
-
|
|
79
|
+
`backup` downloads the active `/config/config.boot` and the complete
|
|
73
80
|
`/config/scripts` tree into the destination directory. It does not enter
|
|
74
81
|
configuration mode or modify the router.
|
|
75
82
|
|
|
76
|
-
|
|
83
|
+
`preflight` validates the config and recursively checks the `scripts/` tree before
|
|
84
|
+
any SSH connection. Executable scripts must use LF line endings, a supported
|
|
85
|
+
shell shebang, and valid executable intent. `release` runs the same checks before
|
|
86
|
+
uploading or changing the router.
|
|
77
87
|
|
|
78
88
|
The deployment workflow:
|
|
79
89
|
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## 2.1.0
|
|
4
|
+
|
|
5
|
+
- **Breaking:** Replace the legacy positional deployment and `--dry-run`
|
|
6
|
+
workflows with the first-class `preflight`, `release`, and `backup`
|
|
7
|
+
commands.
|
|
8
|
+
- Add bundle preflight validation for recursively synchronized scripts,
|
|
9
|
+
including CRLF detection, shell shebang validation, and executable intent.
|
|
10
|
+
- Derive the release SSH target from the configuration's `system host-name`
|
|
11
|
+
and single `system login user` entry.
|
|
12
|
+
- Normalize executable script deployment permissions to `0755` and clean up
|
|
13
|
+
script staging after post-commit synchronization failures.
|
|
14
|
+
- Allow command switches such as `--debug` and `--password-stdin` before or
|
|
15
|
+
after the command name.
|
|
16
|
+
|
|
3
17
|
## 2.0.0
|
|
4
18
|
|
|
5
19
|
- **Breaking:** Replace the direct `ssh2` integration with the shared
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eliware/vyops",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Preflight, release, and back up native VyOS configuration bundles over SSH.",
|
|
5
5
|
"main": "vyops.mjs",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"lint": "eliware-test --lint",
|
|
8
8
|
"test": "eliware-test",
|
|
9
|
-
"deploy": "
|
|
9
|
+
"deploy": "node vyops.mjs",
|
|
10
10
|
"pack": "npm pack --dry-run",
|
|
11
11
|
"validate:package": "npm pack --dry-run --json | node scripts/validate-package.mjs",
|
|
12
12
|
"audit": "node scripts/audit.mjs"
|
package/src/args.mjs
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
export const usage = `Usage:
|
|
2
|
-
vyops
|
|
3
|
-
vyops --
|
|
2
|
+
vyops preflight <config.boot>
|
|
3
|
+
vyops release [--force] [--debug] [--password-stdin] <config.boot>
|
|
4
|
+
vyops backup [--debug] [--password-stdin] <user@hostname-or-ip> <destination-directory>
|
|
4
5
|
vyops --help
|
|
5
6
|
vyops --version`;
|
|
6
7
|
|
|
7
8
|
export function parseArgs(argv) {
|
|
8
9
|
if (argv.length === 1 && argv[0] === '--help') return { help: true };
|
|
9
10
|
if (argv.length === 1 && argv[0] === '--version') return { version: true };
|
|
10
|
-
|
|
11
|
+
const command = argv.find(value => !value.startsWith('--'));
|
|
12
|
+
if (!['preflight', 'release', 'backup'].includes(command)) throw new Error(usage);
|
|
11
13
|
const options = new Set(argv.filter(value => value.startsWith('--')));
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
if (values.length !== 2) throw new Error(usage);
|
|
15
|
-
return { target: values[0], config: values[1]
|
|
14
|
+
const values = argv.filter(value => !value.startsWith('--')).slice(1);
|
|
15
|
+
const allowed = command === 'release' ? ['--force', '--debug', '--password-stdin'] : ['--debug', '--password-stdin'];
|
|
16
|
+
if ([...options].some(option => !allowed.includes(option)) || values.length !== (command === 'backup' ? 2 : 1)) throw new Error(usage);
|
|
17
|
+
return { command, ...(command === 'backup' ? { target: values[0], config: values[1] } : { config: values[0] }), ...(options.has('--force') ? { force: true } : {}), ...(options.has('--debug') ? { debug: true } : {}), ...(options.has('--password-stdin') ? { passwordStdin: true } : {}) };
|
|
16
18
|
}
|
package/src/bundle.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
async function filesIn(directory, root = directory) {
|
|
5
|
+
const result = [];
|
|
6
|
+
let entries;
|
|
7
|
+
try { entries = await fs.readdir(directory, { withFileTypes: true }); }
|
|
8
|
+
catch (error) { if (error.code === 'ENOENT') return result; throw error; }
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const file = join(directory, entry.name);
|
|
11
|
+
if (entry.isDirectory()) result.push(...await filesIn(file, root));
|
|
12
|
+
else if (entry.isFile()) result.push({ file, name: relative(root, file).replaceAll('\\', '/') });
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function targetFromConfig(text) {
|
|
18
|
+
const host = text.match(/(?:^|\n)\s*host-name\s+(?:"([^"]+)"|'([^']+)'|(\S+))/)?.slice(1).find(Boolean);
|
|
19
|
+
const users = [...text.matchAll(/(?:^|\n)\s*user\s+([A-Za-z0-9._-]+)\s*\{/g)].map(match => match[1]);
|
|
20
|
+
if (!host) throw new Error('preflight failed: config does not define system host-name');
|
|
21
|
+
if (users.length !== 1) throw new Error(`preflight failed: expected exactly one system login user; found ${users.length}`);
|
|
22
|
+
return `${users[0]}@${host}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function scriptError(name, message) { throw new Error(`preflight failed: scripts/${name} ${message}`); }
|
|
26
|
+
|
|
27
|
+
export async function validateBundle(config, text, { extractTarget = true } = {}) {
|
|
28
|
+
const scripts = await filesIn(join(config, '..', 'scripts'));
|
|
29
|
+
for (const { file, name } of scripts) {
|
|
30
|
+
const data = await fs.readFile(file);
|
|
31
|
+
const firstLine = data.toString('utf8').split(/\n/, 1)[0].replace(/\r$/, '');
|
|
32
|
+
const executable = firstLine.startsWith('#!') || /(?:\.sh|\.script)$/.test(name)
|
|
33
|
+
|| /^(?:commit\/post-hooks\.d\/|vyos-(?:pre|post)config-bootup\.script$)/.test(name);
|
|
34
|
+
if (!executable) continue;
|
|
35
|
+
if (data.includes(13)) scriptError(name, 'uses CRLF line endings; convert to LF');
|
|
36
|
+
if (!firstLine.startsWith('#!')) scriptError(name, 'is executable but has no shebang');
|
|
37
|
+
const interpreter = firstLine.slice(2).trim().split(/\s+/, 1)[0];
|
|
38
|
+
if (!['/bin/sh', '/bin/bash', '/bin/vbash', '/usr/bin/env'].includes(interpreter)) {
|
|
39
|
+
scriptError(name, `uses unsupported interpreter ${interpreter}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { ...(extractTarget ? { target: targetFromConfig(text) } : {}), scripts };
|
|
43
|
+
}
|
package/src/deploy.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { readFile as readLocalFile } from 'node:fs/promises';
|
|
3
4
|
import { fs, path as eliwarePath, log } from '@eliware/common';
|
|
4
5
|
import { close, connect, download, exec, interactive, upload } from './ssh.mjs';
|
|
5
6
|
|
|
@@ -51,7 +52,14 @@ async function installScripts(client, config, log, runId) {
|
|
|
51
52
|
const installed = `${installDir}/${name}`;
|
|
52
53
|
const backup = `${backupDir}/${name}`;
|
|
53
54
|
const parent = eliwarePath(name, '..').replaceAll(String.fromCharCode(92), '/');
|
|
54
|
-
|
|
55
|
+
let content = Buffer.alloc(0);
|
|
56
|
+
try { content = await readLocalFile(local); }
|
|
57
|
+
/* istanbul ignore next -- missing files are only possible with a mocked directory listing. */
|
|
58
|
+
catch (error) {
|
|
59
|
+
/* istanbul ignore next -- missing files are only possible with a mocked directory listing. */
|
|
60
|
+
if (error.code !== 'ENOENT') throw error;
|
|
61
|
+
}
|
|
62
|
+
const mode = content.toString('utf8').startsWith('#!') || /\.(?:sh|script)$/.test(name)
|
|
55
63
|
|| /^(?:commit[/\\]post-hooks\.d[/\\])/.test(name)
|
|
56
64
|
? 0o755
|
|
57
65
|
: (await fs.promises.stat(local)).mode & 0o777;
|
|
@@ -127,7 +135,10 @@ export async function deploy({ target, config, password }) {
|
|
|
127
135
|
}
|
|
128
136
|
return 0;
|
|
129
137
|
} catch (error) {
|
|
130
|
-
if (finalizeHooks && !hooksFinalized
|
|
138
|
+
if (finalizeHooks && !hooksFinalized) {
|
|
139
|
+
await finalizeHooks(deploymentCommitted, client);
|
|
140
|
+
hooksFinalized = true;
|
|
141
|
+
}
|
|
131
142
|
throw error;
|
|
132
143
|
} finally {
|
|
133
144
|
debugLog('cleaning up remote file');
|
package/src/main.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import packageJson from '../package.json' with { type: 'json' };
|
|
|
3
3
|
import { deploy } from './deploy.mjs';
|
|
4
4
|
import { backup } from './backup.mjs';
|
|
5
5
|
import { readAndValidateConfig } from './validate.mjs';
|
|
6
|
+
import { validateBundle, targetFromConfig } from './bundle.mjs';
|
|
6
7
|
import { pushBack, shouldSkip } from './git.mjs';
|
|
7
8
|
import { closeAll } from './ssh.mjs';
|
|
8
9
|
import { log, registerHandlers, registerSignals } from '@eliware/common';
|
|
@@ -31,18 +32,26 @@ try {
|
|
|
31
32
|
args.password = await readPasswordStdin();
|
|
32
33
|
if (!args.password) throw new Error('password-stdin received an empty password');
|
|
33
34
|
}
|
|
34
|
-
if (args.backup) {
|
|
35
|
+
if (args.command === 'backup') {
|
|
35
36
|
await backup(args);
|
|
36
37
|
log.info(`Backup successful: ${args.config}`);
|
|
37
38
|
process.exit(0);
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
log.info(`Configuration valid; dry run for ${args.target}`);
|
|
40
|
+
if (args.command === 'preflight') {
|
|
41
|
+
const text = await readAndValidateConfig(args.config);
|
|
42
|
+
const { target, scripts } = await validateBundle(args.config, text);
|
|
43
|
+
log.info(`Preflight successful: ${target} (${scripts.length} script files)`);
|
|
44
44
|
process.exit(0);
|
|
45
45
|
}
|
|
46
|
+
if (args.command === 'release') {
|
|
47
|
+
const text = await readAndValidateConfig(args.config);
|
|
48
|
+
await validateBundle(args.config, text);
|
|
49
|
+
args.target = targetFromConfig(text);
|
|
50
|
+
}
|
|
51
|
+
log.debug(`[vyops] validating config: ${args.config}`);
|
|
52
|
+
const configText = await readAndValidateConfig(args.config);
|
|
53
|
+
if (args.command === 'release') await validateBundle(args.config, configText, { extractTarget: true });
|
|
54
|
+
log.debug('[vyops] config validation complete');
|
|
46
55
|
if (!args.force && await shouldSkip(args.config)) {
|
|
47
56
|
log.info('Latest commit is Pushback and config is unchanged; skipping deployment');
|
|
48
57
|
process.exit(0);
|