@cloud-cli/vm 1.3.1 → 1.4.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/.github/workflows/cicd.yml +3 -6
- package/dist/index.d.ts +7 -2
- package/dist/index.js +67 -35
- package/package.json +11 -7
- package/src/index.spec.ts +152 -121
- package/src/index.ts +73 -39
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
name: CICD
|
|
2
2
|
on:
|
|
3
|
-
push
|
|
4
|
-
|
|
5
|
-
- main
|
|
3
|
+
- push
|
|
4
|
+
|
|
6
5
|
jobs:
|
|
7
6
|
release:
|
|
8
|
-
uses:
|
|
9
|
-
with:
|
|
10
|
-
branches: main
|
|
7
|
+
uses: cloud-cli/workflows/.github/workflows/npm-build-release.yml@main
|
|
11
8
|
secrets:
|
|
12
9
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
13
10
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
import { help } from "@cloud-cli/cli";
|
|
2
|
+
interface Options {
|
|
3
|
+
_?: string[];
|
|
4
|
+
}
|
|
5
|
+
interface NameOption extends Options {
|
|
2
6
|
name: string;
|
|
3
7
|
}
|
|
4
|
-
interface NameAndPathOption {
|
|
8
|
+
interface NameAndPathOption extends Options {
|
|
5
9
|
name: string;
|
|
6
10
|
path?: string;
|
|
7
11
|
}
|
|
@@ -28,5 +32,6 @@ declare const _default: {
|
|
|
28
32
|
rm: typeof rm;
|
|
29
33
|
cat: typeof cat;
|
|
30
34
|
fixPermissions: typeof fixPermissions;
|
|
35
|
+
[help]: () => string;
|
|
31
36
|
};
|
|
32
37
|
export default _default;
|
package/dist/index.js
CHANGED
|
@@ -1,61 +1,70 @@
|
|
|
1
|
-
import { join } from
|
|
2
|
-
import { readFile } from
|
|
3
|
-
import { exec } from
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { exec } from "@cloud-cli/exec";
|
|
4
|
+
import { help } from "@cloud-cli/cli";
|
|
4
5
|
const nameRe = /^[a-z][a-z0-9-]+$/;
|
|
5
6
|
const isValidName = (name) => nameRe.test(name);
|
|
6
|
-
const invalidNameError = new Error(
|
|
7
|
+
const invalidNameError = new Error("Invalid name");
|
|
8
|
+
const readOptions = (options) => {
|
|
9
|
+
options.name || (options.name = options._?.[0]);
|
|
10
|
+
if (!isValidName(options.name)) {
|
|
11
|
+
throw invalidNameError;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
7
14
|
async function list() {
|
|
8
|
-
const output = await exec(
|
|
15
|
+
const output = await exec("docker", ["volume", "ls", "--format={{.Name}}"]);
|
|
9
16
|
if (!output.ok) {
|
|
10
|
-
throw new Error(
|
|
17
|
+
throw new Error("Unable to list volumes");
|
|
11
18
|
}
|
|
12
|
-
return output.stdout.trim().split(
|
|
19
|
+
return output.stdout.trim().split("\n");
|
|
13
20
|
}
|
|
14
21
|
async function getVolumeMountpoint(name) {
|
|
15
|
-
const output = await exec(
|
|
22
|
+
const output = await exec("docker", ["volume", "inspect", name]);
|
|
16
23
|
const volumes = JSON.parse(output.stdout);
|
|
17
24
|
if (!volumes.length) {
|
|
18
|
-
throw new Error(
|
|
25
|
+
throw new Error("Volume not found");
|
|
19
26
|
}
|
|
20
27
|
return volumes[0].Mountpoint;
|
|
21
28
|
}
|
|
22
29
|
async function ls(options) {
|
|
23
|
-
|
|
30
|
+
readOptions(options);
|
|
31
|
+
const { name, path = "" } = options;
|
|
24
32
|
const root = await getVolumeMountpoint(name);
|
|
25
|
-
const files = await exec(
|
|
26
|
-
return files.stdout.trim().split(
|
|
33
|
+
const files = await exec("ls", ["-1", join(root, path)]);
|
|
34
|
+
return files.stdout.trim().split("\n").filter(Boolean);
|
|
27
35
|
}
|
|
28
36
|
async function fixPermissions(options) {
|
|
37
|
+
readOptions(options);
|
|
29
38
|
const { name } = options;
|
|
30
39
|
const root = await getVolumeMountpoint(name);
|
|
31
|
-
const result = await exec(
|
|
40
|
+
const result = await exec("chmod", ["-R", "a+w", root]);
|
|
32
41
|
return result.ok;
|
|
33
42
|
}
|
|
34
43
|
async function rm(options) {
|
|
44
|
+
readOptions(options);
|
|
35
45
|
const { name, path } = options;
|
|
36
46
|
if (!path) {
|
|
37
|
-
throw new Error(
|
|
47
|
+
throw new Error("Path not specified");
|
|
38
48
|
}
|
|
39
49
|
const root = await getVolumeMountpoint(name);
|
|
40
|
-
const result = await exec(
|
|
50
|
+
const result = await exec("rm", ["-r", join(root, path)]);
|
|
41
51
|
return result.ok;
|
|
42
52
|
}
|
|
43
53
|
async function cat(options) {
|
|
54
|
+
readOptions(options);
|
|
44
55
|
const { name, path } = options;
|
|
45
56
|
if (!path) {
|
|
46
|
-
throw new Error(
|
|
57
|
+
throw new Error("Path not specified");
|
|
47
58
|
}
|
|
48
59
|
const root = await getVolumeMountpoint(name);
|
|
49
|
-
return await readFile(join(root, path),
|
|
60
|
+
return await readFile(join(root, path), "utf8");
|
|
50
61
|
}
|
|
51
62
|
async function show(options) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
const output = await exec('docker', ['volume', 'inspect', options.name]);
|
|
63
|
+
readOptions(options);
|
|
64
|
+
const output = await exec("docker", ["volume", "inspect", options.name]);
|
|
56
65
|
const list = JSON.parse(output.stdout);
|
|
57
66
|
if (!list.length) {
|
|
58
|
-
throw new Error(
|
|
67
|
+
throw new Error("Volume not found");
|
|
59
68
|
}
|
|
60
69
|
const { Name, Labels, CreatedAt } = list[0];
|
|
61
70
|
return {
|
|
@@ -65,27 +74,50 @@ async function show(options) {
|
|
|
65
74
|
};
|
|
66
75
|
}
|
|
67
76
|
async function add(options) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
const output = await exec('docker', ['volume', 'create', options.name]);
|
|
77
|
+
readOptions(options);
|
|
78
|
+
const output = await exec("docker", ["volume", "create", options.name]);
|
|
72
79
|
if (output.ok) {
|
|
80
|
+
await fixPermissions(options);
|
|
73
81
|
return true;
|
|
74
82
|
}
|
|
75
|
-
throw new Error(
|
|
83
|
+
throw new Error("Unable to create volume");
|
|
76
84
|
}
|
|
77
85
|
async function remove(options) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
const output = await exec('docker', ['volume', 'rm', options.name]);
|
|
86
|
+
readOptions(options);
|
|
87
|
+
const output = await exec("docker", ["volume", "rm", options.name]);
|
|
82
88
|
if (output.ok) {
|
|
83
89
|
return true;
|
|
84
90
|
}
|
|
85
|
-
throw new Error(
|
|
91
|
+
throw new Error("Unable to remove volume");
|
|
86
92
|
}
|
|
87
93
|
async function prune() {
|
|
88
|
-
await exec(
|
|
89
|
-
return
|
|
94
|
+
await exec("docker", ["volume", "prune"]);
|
|
95
|
+
return "";
|
|
90
96
|
}
|
|
91
|
-
export default {
|
|
97
|
+
export default {
|
|
98
|
+
add,
|
|
99
|
+
remove,
|
|
100
|
+
list,
|
|
101
|
+
show,
|
|
102
|
+
prune,
|
|
103
|
+
ls,
|
|
104
|
+
rm,
|
|
105
|
+
cat,
|
|
106
|
+
fixPermissions,
|
|
107
|
+
[help]: () => `Manage Docker volumes
|
|
108
|
+
|
|
109
|
+
Available commands:
|
|
110
|
+
vm.add --name <name> - Create a new Docker volume (name must be lowercase alphanumeric with hyphens)
|
|
111
|
+
vm.remove --name <name> - Remove a Docker volume
|
|
112
|
+
vm.list - List all Docker volumes
|
|
113
|
+
vm.ls --name <name> [--path <path>] - List files in a volume
|
|
114
|
+
vm.cat --name <name> --path <path> - Read a file from a volume
|
|
115
|
+
vm.show --name <name> - Show volume details
|
|
116
|
+
vm.fixPermissions --name <name> - Fix permissions on a volume
|
|
117
|
+
vm.rm --name <name> --path <path> - Remove a file or directory from a volume
|
|
118
|
+
vm.prune - Remove all unused Docker volumes
|
|
119
|
+
|
|
120
|
+
Options:
|
|
121
|
+
name - Volume name (lowercase alphanumeric with hyphens)
|
|
122
|
+
path - Path within the volume`,
|
|
123
|
+
};
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloud-cli/vm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Docker volume management",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"types": "dist/index.d.ts",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"prettier": "@cloud-cli/prettier-config",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "
|
|
11
|
-
"tdd": "jest --watchAll",
|
|
10
|
+
"test": "vitest",
|
|
12
11
|
"build": "tsc"
|
|
13
12
|
},
|
|
14
13
|
"keywords": [
|
|
@@ -24,11 +23,16 @@
|
|
|
24
23
|
"author": "Darlan Alves <me@darlanalv.es>",
|
|
25
24
|
"license": "MIT",
|
|
26
25
|
"devDependencies": {
|
|
27
|
-
"@cloud-cli/jest-config": "^1.0.0",
|
|
28
26
|
"@cloud-cli/prettier-config": "^1.0.0",
|
|
29
|
-
"@cloud-cli/typescript-config": "^1.0.1"
|
|
27
|
+
"@cloud-cli/typescript-config": "^1.0.1",
|
|
28
|
+
"@types/node": "^20.19.43",
|
|
29
|
+
"typescript": "^5.4.5",
|
|
30
|
+
"vitest": "^1.6.0"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@cloud-cli/exec": "^1.0.1"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@cloud-cli/cli": "^2.2.0"
|
|
33
37
|
}
|
|
34
38
|
}
|
package/src/index.spec.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { help } from "@cloud-cli/cli";
|
|
3
|
+
import vm from "./index";
|
|
4
|
+
|
|
5
|
+
const execMocks = vi.hoisted(() => ({
|
|
6
|
+
exec: vi.fn(),
|
|
7
|
+
readFile: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("get-port", () => ({ default: vi.fn().mockReturnValue(1234) }));
|
|
11
|
+
vi.mock("@cloud-cli/exec", () => ({ exec: execMocks.exec }));
|
|
12
|
+
vi.mock("node:fs/promises", () => ({ readFile: execMocks.readFile }));
|
|
13
|
+
vi.mock("@cloud-cli/cli", async (importOriginal) => {
|
|
14
|
+
const mod: any = await importOriginal();
|
|
15
|
+
return {
|
|
16
|
+
...mod,
|
|
17
|
+
help: mod.help,
|
|
18
|
+
};
|
|
19
|
+
});
|
|
15
20
|
|
|
16
21
|
const inspectOutput = `[{
|
|
17
22
|
"CreatedAt": "2023-03-10T10:15:10Z",
|
|
@@ -26,191 +31,217 @@ const inspectOutput = `[{
|
|
|
26
31
|
"Scope": "local"
|
|
27
32
|
}]`;
|
|
28
33
|
|
|
29
|
-
describe(
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
describe("help", () => {
|
|
35
|
+
it("should have a [help] Symbol export that is a function", () => {
|
|
36
|
+
expect(vm[help]).toBeDefined();
|
|
37
|
+
expect(typeof vm[help]).toBe("function");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return a string help text", () => {
|
|
41
|
+
const helpText = vm[help]();
|
|
42
|
+
expect(typeof helpText).toBe("string");
|
|
43
|
+
expect(helpText).toContain("Docker");
|
|
44
|
+
expect(helpText).toContain("vm.add");
|
|
45
|
+
expect(helpText).toContain("vm.remove");
|
|
46
|
+
expect(helpText).toContain("vm.list");
|
|
47
|
+
expect(helpText).toContain("vm.ls");
|
|
48
|
+
expect(helpText).toContain("vm.cat");
|
|
49
|
+
expect(helpText).toContain("vm.show");
|
|
50
|
+
expect(helpText).toContain("vm.fixPermissions");
|
|
51
|
+
expect(helpText).toContain("vm.rm");
|
|
52
|
+
expect(helpText).toContain("vm.prune");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should not expose "help" as a normal command key', () => {
|
|
56
|
+
expect(Object.hasOwn(vm, "help")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("volume manager", () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
execMocks.exec.mockReset();
|
|
63
|
+
execMocks.readFile.mockReset();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("list", () => {
|
|
67
|
+
it("should list the available volumes", async () => {
|
|
32
68
|
const execOutput = {
|
|
33
69
|
ok: true,
|
|
34
70
|
stdout: `test\ntest2\n`,
|
|
35
71
|
};
|
|
36
72
|
|
|
37
|
-
execMocks.exec
|
|
73
|
+
execMocks.exec.mockResolvedValue(execOutput);
|
|
38
74
|
|
|
39
|
-
await expect(vm.list()).resolves.toEqual([
|
|
40
|
-
expect(
|
|
75
|
+
await expect(vm.list()).resolves.toEqual(["test", "test2"]);
|
|
76
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "ls", "--format={{.Name}}"]);
|
|
41
77
|
});
|
|
42
78
|
|
|
43
|
-
it(
|
|
79
|
+
it("should reject in case of error", async () => {
|
|
44
80
|
const output = { ok: false };
|
|
45
|
-
execMocks.exec
|
|
81
|
+
execMocks.exec.mockResolvedValue(output);
|
|
46
82
|
|
|
47
|
-
await expect(vm.list()).rejects.toThrowError(
|
|
83
|
+
await expect(vm.list()).rejects.toThrowError("Unable to list volumes");
|
|
48
84
|
});
|
|
49
85
|
});
|
|
50
86
|
|
|
51
|
-
describe(
|
|
52
|
-
it(
|
|
53
|
-
const outputs = [inspectOutput,
|
|
54
|
-
execMocks.exec
|
|
87
|
+
describe("fixPermissions", () => {
|
|
88
|
+
it("should fix write permissions on volume folder", async () => {
|
|
89
|
+
const outputs = [inspectOutput, ""];
|
|
90
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
|
|
55
91
|
|
|
56
|
-
await expect(vm.fixPermissions({ name:
|
|
57
|
-
expect(
|
|
58
|
-
expect(
|
|
92
|
+
await expect(vm.fixPermissions({ name: "test" })).resolves.toEqual(true);
|
|
93
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
|
|
94
|
+
expect(execMocks.exec).toHaveBeenCalledWith("chmod", ["-R", "a+w", "/var/lib/docker/volumes/test/_data"]);
|
|
59
95
|
});
|
|
60
96
|
});
|
|
61
97
|
|
|
62
|
-
describe(
|
|
63
|
-
it(
|
|
64
|
-
const outputs = [inspectOutput,
|
|
65
|
-
execMocks.exec
|
|
98
|
+
describe("ls", () => {
|
|
99
|
+
it("should list files of a volume and path", async () => {
|
|
100
|
+
const outputs = [inspectOutput, "dir", inspectOutput, "a.txt\nb.txt"];
|
|
101
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
|
|
66
102
|
|
|
67
|
-
await expect(vm.ls({ name:
|
|
68
|
-
await expect(vm.ls({ name:
|
|
103
|
+
await expect(vm.ls({ name: "test" })).resolves.toEqual(["dir"]);
|
|
104
|
+
await expect(vm.ls({ name: "test", path: "dir" })).resolves.toEqual(["a.txt", "b.txt"]);
|
|
69
105
|
|
|
70
|
-
expect(
|
|
71
|
-
expect(
|
|
72
|
-
expect(
|
|
106
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
|
|
107
|
+
expect(execMocks.exec).toHaveBeenCalledWith("ls", ["-1", "/var/lib/docker/volumes/test/_data"]);
|
|
108
|
+
expect(execMocks.exec).toHaveBeenCalledWith("ls", ["-1", "/var/lib/docker/volumes/test/_data/dir"]);
|
|
73
109
|
});
|
|
74
110
|
|
|
75
|
-
it(
|
|
76
|
-
execMocks.exec
|
|
77
|
-
await expect(vm.ls({ name:
|
|
111
|
+
it("should throw an error if volume name is invalid", async () => {
|
|
112
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: "[]" }));
|
|
113
|
+
await expect(vm.ls({ name: "notfound" })).rejects.toThrowError("Volume not found");
|
|
78
114
|
});
|
|
79
115
|
});
|
|
80
116
|
|
|
81
|
-
describe(
|
|
82
|
-
it(
|
|
83
|
-
const outputs = [inspectOutput,
|
|
84
|
-
execMocks.exec
|
|
117
|
+
describe("rm", () => {
|
|
118
|
+
it("should remove files of a volume by path", async () => {
|
|
119
|
+
const outputs = [inspectOutput, ""];
|
|
120
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
|
|
85
121
|
|
|
86
|
-
await expect(vm.rm({ name:
|
|
122
|
+
await expect(vm.rm({ name: "test", path: "file.txt" })).resolves.toEqual(true);
|
|
87
123
|
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
124
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
|
|
125
|
+
expect(execMocks.exec).toHaveBeenCalledWith("rm", ["-r", "/var/lib/docker/volumes/test/_data/file.txt"]);
|
|
90
126
|
});
|
|
91
127
|
|
|
92
|
-
it(
|
|
93
|
-
execMocks.exec
|
|
94
|
-
await expect(vm.rm({ name:
|
|
128
|
+
it("should throw an error if volume name is invalid", async () => {
|
|
129
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: "[]" }));
|
|
130
|
+
await expect(vm.rm({ name: "notfound", path: "file.txt" })).rejects.toThrowError("Volume not found");
|
|
95
131
|
});
|
|
96
132
|
|
|
97
|
-
it(
|
|
98
|
-
execMocks.exec
|
|
99
|
-
await expect(vm.rm({ name:
|
|
133
|
+
it("should throw an error if path is invalid", async () => {
|
|
134
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
|
|
135
|
+
await expect(vm.rm({ name: "nopath" })).rejects.toThrowError("Path not specified");
|
|
100
136
|
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
101
137
|
});
|
|
102
138
|
});
|
|
103
139
|
|
|
104
|
-
describe(
|
|
105
|
-
it(
|
|
106
|
-
execMocks.exec
|
|
107
|
-
|
|
140
|
+
describe("cat", () => {
|
|
141
|
+
it("should read the content of a file in a volume by path", async () => {
|
|
142
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
|
|
143
|
+
execMocks.readFile.mockImplementation(() => "test file");
|
|
108
144
|
|
|
109
|
-
await expect(vm.cat({ name:
|
|
145
|
+
await expect(vm.cat({ name: "test", path: "file.txt" })).resolves.toEqual("test file");
|
|
110
146
|
|
|
111
|
-
expect(
|
|
112
|
-
expect(
|
|
147
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
|
|
148
|
+
expect(execMocks.readFile).toHaveBeenCalledWith("/var/lib/docker/volumes/test/_data/file.txt", "utf8");
|
|
113
149
|
});
|
|
114
150
|
|
|
115
|
-
it(
|
|
116
|
-
execMocks.exec
|
|
117
|
-
await expect(vm.cat({ name:
|
|
151
|
+
it("should throw an error if volume name is invalid", async () => {
|
|
152
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: "[]" }));
|
|
153
|
+
await expect(vm.cat({ name: "notfound", path: "file.txt" })).rejects.toThrowError("Volume not found");
|
|
118
154
|
});
|
|
119
155
|
|
|
120
|
-
it(
|
|
121
|
-
execMocks.exec
|
|
122
|
-
await expect(vm.cat({ name:
|
|
156
|
+
it("should throw an error if path is invalid", async () => {
|
|
157
|
+
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
|
|
158
|
+
await expect(vm.cat({ name: "nopath" })).rejects.toThrowError("Path not specified");
|
|
123
159
|
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
124
160
|
});
|
|
125
161
|
});
|
|
126
162
|
|
|
127
|
-
describe(
|
|
128
|
-
it(
|
|
163
|
+
describe("show", () => {
|
|
164
|
+
it("should show details of a volume", async () => {
|
|
129
165
|
const execOutput = { ok: true, stdout: inspectOutput };
|
|
130
|
-
execMocks.exec
|
|
166
|
+
execMocks.exec.mockResolvedValue(execOutput);
|
|
131
167
|
|
|
132
|
-
await expect(vm.show({ name:
|
|
133
|
-
name:
|
|
134
|
-
createdAt:
|
|
168
|
+
await expect(vm.show({ name: "test" })).resolves.toEqual({
|
|
169
|
+
name: "test",
|
|
170
|
+
createdAt: "2023-03-10T10:15:10Z",
|
|
135
171
|
labels: {
|
|
136
|
-
origin:
|
|
137
|
-
version:
|
|
172
|
+
origin: "test/container",
|
|
173
|
+
version: "534524",
|
|
138
174
|
},
|
|
139
175
|
});
|
|
140
176
|
|
|
141
|
-
expect(
|
|
177
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
|
|
142
178
|
});
|
|
143
179
|
|
|
144
|
-
it(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
expect(exec.exec).not.toHaveBeenCalled();
|
|
180
|
+
it("should throw an error if volume name is invalid", async () => {
|
|
181
|
+
await expect(vm.show({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
|
|
182
|
+
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
148
183
|
});
|
|
149
184
|
|
|
150
|
-
it(
|
|
151
|
-
const output = { ok: true, stdout:
|
|
152
|
-
execMocks.exec
|
|
185
|
+
it("should throw error if volume does not exist", async () => {
|
|
186
|
+
const output = { ok: true, stdout: "[]" };
|
|
187
|
+
execMocks.exec.mockResolvedValue(output);
|
|
153
188
|
|
|
154
|
-
await expect(vm.show({ name:
|
|
189
|
+
await expect(vm.show({ name: "invalid" })).rejects.toThrowError("Volume not found");
|
|
155
190
|
});
|
|
156
191
|
});
|
|
157
192
|
|
|
158
|
-
describe(
|
|
159
|
-
it(
|
|
160
|
-
|
|
161
|
-
|
|
193
|
+
describe("add", () => {
|
|
194
|
+
it("should add a volume", async () => {
|
|
195
|
+
execMocks.exec
|
|
196
|
+
.mockResolvedValueOnce({ ok: true, stdout: "test" })
|
|
197
|
+
.mockResolvedValueOnce({ ok: true, stdout: inspectOutput })
|
|
198
|
+
.mockResolvedValueOnce({ ok: true, stdout: "" });
|
|
162
199
|
|
|
163
|
-
await expect(vm.add({ name:
|
|
200
|
+
await expect(vm.add({ name: "test" })).resolves.toEqual(true);
|
|
164
201
|
|
|
165
|
-
expect(
|
|
202
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "create", "test"]);
|
|
166
203
|
});
|
|
167
204
|
|
|
168
|
-
it(
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
expect(exec.exec).not.toHaveBeenCalled();
|
|
205
|
+
it("should throw error if volume name is invalid", async () => {
|
|
206
|
+
await expect(vm.add({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
|
|
207
|
+
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
172
208
|
});
|
|
173
209
|
|
|
174
|
-
it(
|
|
175
|
-
const output = { ok: false, stdout:
|
|
176
|
-
execMocks.exec
|
|
210
|
+
it("should throw error if volume creation fails", async () => {
|
|
211
|
+
const output = { ok: false, stdout: "" };
|
|
212
|
+
execMocks.exec.mockResolvedValue(output);
|
|
177
213
|
|
|
178
|
-
await expect(vm.add({ name:
|
|
214
|
+
await expect(vm.add({ name: "failed" })).rejects.toThrowError("Unable to create volume");
|
|
179
215
|
});
|
|
180
216
|
});
|
|
181
217
|
|
|
182
|
-
describe(
|
|
183
|
-
it(
|
|
184
|
-
const execOutput = { ok: true, stdout:
|
|
185
|
-
execMocks.exec
|
|
186
|
-
|
|
187
|
-
await expect(vm.remove({ name: 'test' })).resolves.toEqual(true);
|
|
218
|
+
describe("remove", () => {
|
|
219
|
+
it("should add a volume", async () => {
|
|
220
|
+
const execOutput = { ok: true, stdout: "" };
|
|
221
|
+
execMocks.exec.mockResolvedValue(execOutput);
|
|
188
222
|
|
|
189
|
-
expect(
|
|
223
|
+
await expect(vm.remove({ name: "test" })).resolves.toEqual(true);
|
|
190
224
|
});
|
|
191
225
|
|
|
192
|
-
it(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
expect(exec.exec).not.toHaveBeenCalled();
|
|
226
|
+
it("should throw error if volume name is invalid", async () => {
|
|
227
|
+
await expect(vm.remove({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
|
|
228
|
+
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
196
229
|
});
|
|
197
230
|
|
|
198
|
-
it(
|
|
199
|
-
const output = { ok: false, stdout:
|
|
200
|
-
execMocks.exec
|
|
231
|
+
it("should throw error if volume removal fails", async () => {
|
|
232
|
+
const output = { ok: false, stdout: "" };
|
|
233
|
+
execMocks.exec.mockResolvedValue(output);
|
|
201
234
|
|
|
202
|
-
await expect(vm.remove({ name:
|
|
235
|
+
await expect(vm.remove({ name: "failed" })).rejects.toThrowError("Unable to remove volume");
|
|
203
236
|
});
|
|
204
237
|
});
|
|
205
238
|
|
|
206
|
-
describe(
|
|
207
|
-
it(
|
|
208
|
-
const execOutput = { ok: true, stdout:
|
|
209
|
-
execMocks.exec
|
|
210
|
-
|
|
211
|
-
await expect(vm.prune()).resolves.toEqual('');
|
|
239
|
+
describe("prune", () => {
|
|
240
|
+
it("should remove ununsed volumes", async () => {
|
|
241
|
+
const execOutput = { ok: true, stdout: "" };
|
|
242
|
+
execMocks.exec.mockResolvedValue(execOutput);
|
|
212
243
|
|
|
213
|
-
expect(
|
|
244
|
+
await expect(vm.prune()).resolves.toEqual("");
|
|
214
245
|
});
|
|
215
246
|
});
|
|
216
247
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,91 +1,104 @@
|
|
|
1
|
-
import { join } from
|
|
2
|
-
import { readFile } from
|
|
3
|
-
import { exec } from
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { exec } from "@cloud-cli/exec";
|
|
4
|
+
import { help } from "@cloud-cli/cli";
|
|
4
5
|
|
|
5
6
|
const nameRe = /^[a-z][a-z0-9-]+$/;
|
|
6
7
|
const isValidName = (name) => nameRe.test(name);
|
|
7
|
-
const invalidNameError = new Error(
|
|
8
|
+
const invalidNameError = new Error("Invalid name");
|
|
9
|
+
const readOptions = (options: NameOption) => {
|
|
10
|
+
options.name ||= options._?.[0];
|
|
11
|
+
if (!isValidName(options.name)) {
|
|
12
|
+
throw invalidNameError;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
interface Options {
|
|
17
|
+
_?: string[];
|
|
18
|
+
}
|
|
8
19
|
|
|
9
|
-
interface NameOption {
|
|
20
|
+
interface NameOption extends Options {
|
|
10
21
|
name: string;
|
|
11
22
|
}
|
|
12
23
|
|
|
13
|
-
interface NameAndPathOption {
|
|
24
|
+
interface NameAndPathOption extends Options {
|
|
14
25
|
name: string;
|
|
15
26
|
path?: string;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
async function list() {
|
|
19
|
-
const output = await exec(
|
|
30
|
+
const output = await exec("docker", ["volume", "ls", "--format={{.Name}}"]);
|
|
20
31
|
|
|
21
32
|
if (!output.ok) {
|
|
22
|
-
throw new Error(
|
|
33
|
+
throw new Error("Unable to list volumes");
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
return output.stdout.trim().split(
|
|
36
|
+
return output.stdout.trim().split("\n");
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
async function getVolumeMountpoint(name): Promise<string> {
|
|
29
|
-
const output = await exec(
|
|
40
|
+
const output = await exec("docker", ["volume", "inspect", name]);
|
|
30
41
|
const volumes = JSON.parse(output.stdout);
|
|
31
42
|
|
|
32
43
|
if (!volumes.length) {
|
|
33
|
-
throw new Error(
|
|
44
|
+
throw new Error("Volume not found");
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
return volumes[0].Mountpoint;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
async function ls(options: NameAndPathOption) {
|
|
40
|
-
|
|
51
|
+
readOptions(options);
|
|
52
|
+
const { name, path = "" } = options;
|
|
41
53
|
const root = await getVolumeMountpoint(name);
|
|
42
|
-
const files = await exec(
|
|
54
|
+
const files = await exec("ls", ["-1", join(root, path)]);
|
|
43
55
|
|
|
44
|
-
return files.stdout.trim().split(
|
|
56
|
+
return files.stdout.trim().split("\n").filter(Boolean);
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
async function fixPermissions(options: NameOption) {
|
|
60
|
+
readOptions(options);
|
|
48
61
|
const { name } = options;
|
|
49
62
|
const root = await getVolumeMountpoint(name);
|
|
50
|
-
const result = await exec(
|
|
63
|
+
const result = await exec("chmod", ["-R", "a+w", root]);
|
|
51
64
|
|
|
52
65
|
return result.ok;
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
async function rm(options: NameAndPathOption) {
|
|
69
|
+
readOptions(options);
|
|
56
70
|
const { name, path } = options;
|
|
57
71
|
|
|
58
72
|
if (!path) {
|
|
59
|
-
throw new Error(
|
|
73
|
+
throw new Error("Path not specified");
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
const root = await getVolumeMountpoint(name);
|
|
63
|
-
const result = await exec(
|
|
77
|
+
const result = await exec("rm", ["-r", join(root, path)]);
|
|
64
78
|
|
|
65
79
|
return result.ok;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
async function cat(options: NameAndPathOption) {
|
|
83
|
+
readOptions(options);
|
|
69
84
|
const { name, path } = options;
|
|
70
85
|
|
|
71
86
|
if (!path) {
|
|
72
|
-
throw new Error(
|
|
87
|
+
throw new Error("Path not specified");
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
const root = await getVolumeMountpoint(name);
|
|
76
|
-
return await readFile(join(root, path),
|
|
91
|
+
return await readFile(join(root, path), "utf8");
|
|
77
92
|
}
|
|
78
93
|
|
|
79
94
|
async function show(options: NameOption) {
|
|
80
|
-
|
|
81
|
-
throw invalidNameError;
|
|
82
|
-
}
|
|
95
|
+
readOptions(options);
|
|
83
96
|
|
|
84
|
-
const output = await exec(
|
|
97
|
+
const output = await exec("docker", ["volume", "inspect", options.name]);
|
|
85
98
|
const list = JSON.parse(output.stdout);
|
|
86
99
|
|
|
87
100
|
if (!list.length) {
|
|
88
|
-
throw new Error(
|
|
101
|
+
throw new Error("Volume not found");
|
|
89
102
|
}
|
|
90
103
|
|
|
91
104
|
const { Name, Labels, CreatedAt } = list[0];
|
|
@@ -98,36 +111,57 @@ async function show(options: NameOption) {
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
async function add(options: NameOption) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const output = await exec('docker', ['volume', 'create', options.name]);
|
|
114
|
+
readOptions(options);
|
|
115
|
+
const output = await exec("docker", ["volume", "create", options.name]);
|
|
106
116
|
|
|
107
117
|
if (output.ok) {
|
|
118
|
+
await fixPermissions(options);
|
|
108
119
|
return true;
|
|
109
120
|
}
|
|
110
121
|
|
|
111
|
-
throw new Error(
|
|
122
|
+
throw new Error("Unable to create volume");
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
async function remove(options: NameOption) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const output = await exec('docker', ['volume', 'rm', options.name]);
|
|
126
|
+
readOptions(options);
|
|
127
|
+
const output = await exec("docker", ["volume", "rm", options.name]);
|
|
120
128
|
|
|
121
129
|
if (output.ok) {
|
|
122
130
|
return true;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
throw new Error(
|
|
133
|
+
throw new Error("Unable to remove volume");
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
async function prune() {
|
|
129
|
-
await exec(
|
|
130
|
-
return
|
|
137
|
+
await exec("docker", ["volume", "prune"]);
|
|
138
|
+
return "";
|
|
131
139
|
}
|
|
132
140
|
|
|
133
|
-
export default {
|
|
141
|
+
export default {
|
|
142
|
+
add,
|
|
143
|
+
remove,
|
|
144
|
+
list,
|
|
145
|
+
show,
|
|
146
|
+
prune,
|
|
147
|
+
ls,
|
|
148
|
+
rm,
|
|
149
|
+
cat,
|
|
150
|
+
fixPermissions,
|
|
151
|
+
[help]: () => `Manage Docker volumes
|
|
152
|
+
|
|
153
|
+
Available commands:
|
|
154
|
+
vm.add --name <name> - Create a new Docker volume (name must be lowercase alphanumeric with hyphens)
|
|
155
|
+
vm.remove --name <name> - Remove a Docker volume
|
|
156
|
+
vm.list - List all Docker volumes
|
|
157
|
+
vm.ls --name <name> [--path <path>] - List files in a volume
|
|
158
|
+
vm.cat --name <name> --path <path> - Read a file from a volume
|
|
159
|
+
vm.show --name <name> - Show volume details
|
|
160
|
+
vm.fixPermissions --name <name> - Fix permissions on a volume
|
|
161
|
+
vm.rm --name <name> --path <path> - Remove a file or directory from a volume
|
|
162
|
+
vm.prune - Remove all unused Docker volumes
|
|
163
|
+
|
|
164
|
+
Options:
|
|
165
|
+
name - Volume name (lowercase alphanumeric with hyphens)
|
|
166
|
+
path - Path within the volume`,
|
|
167
|
+
};
|