@cloud-cli/vm 1.3.2 → 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/dist/index.d.ts +7 -2
- package/dist/index.js +67 -35
- package/package.json +3 -2
- package/src/index.spec.ts +124 -95
- package/src/index.ts +73 -39
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloud-cli/vm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Docker volume management",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@cloud-cli/prettier-config": "^1.0.0",
|
|
27
27
|
"@cloud-cli/typescript-config": "^1.0.1",
|
|
28
|
+
"@types/node": "^20.19.43",
|
|
28
29
|
"typescript": "^5.4.5",
|
|
29
30
|
"vitest": "^1.6.0"
|
|
30
31
|
},
|
|
@@ -32,6 +33,6 @@
|
|
|
32
33
|
"@cloud-cli/exec": "^1.0.1"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
|
-
"@cloud-cli/cli": "^2.
|
|
36
|
+
"@cloud-cli/cli": "^2.2.0"
|
|
36
37
|
}
|
|
37
38
|
}
|
package/src/index.spec.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach } from
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { help } from "@cloud-cli/cli";
|
|
3
|
+
import vm from "./index";
|
|
2
4
|
|
|
3
5
|
const execMocks = vi.hoisted(() => ({
|
|
4
6
|
exec: vi.fn(),
|
|
5
7
|
readFile: vi.fn(),
|
|
6
8
|
}));
|
|
7
9
|
|
|
8
|
-
vi.mock(
|
|
9
|
-
vi.mock(
|
|
10
|
-
|
|
11
|
-
vi.mock(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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,14 +31,40 @@ const inspectOutput = `[{
|
|
|
26
31
|
"Scope": "local"
|
|
27
32
|
}]`;
|
|
28
33
|
|
|
29
|
-
describe(
|
|
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", () => {
|
|
30
61
|
beforeEach(() => {
|
|
31
62
|
execMocks.exec.mockReset();
|
|
32
63
|
execMocks.readFile.mockReset();
|
|
33
64
|
});
|
|
34
65
|
|
|
35
|
-
describe(
|
|
36
|
-
it(
|
|
66
|
+
describe("list", () => {
|
|
67
|
+
it("should list the available volumes", async () => {
|
|
37
68
|
const execOutput = {
|
|
38
69
|
ok: true,
|
|
39
70
|
stdout: `test\ntest2\n`,
|
|
@@ -41,178 +72,176 @@ describe('volume manager', () => {
|
|
|
41
72
|
|
|
42
73
|
execMocks.exec.mockResolvedValue(execOutput);
|
|
43
74
|
|
|
44
|
-
await expect(vm.list()).resolves.toEqual([
|
|
45
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
75
|
+
await expect(vm.list()).resolves.toEqual(["test", "test2"]);
|
|
76
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "ls", "--format={{.Name}}"]);
|
|
46
77
|
});
|
|
47
78
|
|
|
48
|
-
it(
|
|
79
|
+
it("should reject in case of error", async () => {
|
|
49
80
|
const output = { ok: false };
|
|
50
81
|
execMocks.exec.mockResolvedValue(output);
|
|
51
82
|
|
|
52
|
-
await expect(vm.list()).rejects.toThrowError(
|
|
83
|
+
await expect(vm.list()).rejects.toThrowError("Unable to list volumes");
|
|
53
84
|
});
|
|
54
85
|
});
|
|
55
86
|
|
|
56
|
-
describe(
|
|
57
|
-
it(
|
|
58
|
-
const outputs = [inspectOutput,
|
|
87
|
+
describe("fixPermissions", () => {
|
|
88
|
+
it("should fix write permissions on volume folder", async () => {
|
|
89
|
+
const outputs = [inspectOutput, ""];
|
|
59
90
|
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
|
|
60
91
|
|
|
61
|
-
await expect(vm.fixPermissions({ name:
|
|
62
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
63
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
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"]);
|
|
64
95
|
});
|
|
65
96
|
});
|
|
66
97
|
|
|
67
|
-
describe(
|
|
68
|
-
it(
|
|
69
|
-
const outputs = [inspectOutput,
|
|
98
|
+
describe("ls", () => {
|
|
99
|
+
it("should list files of a volume and path", async () => {
|
|
100
|
+
const outputs = [inspectOutput, "dir", inspectOutput, "a.txt\nb.txt"];
|
|
70
101
|
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
|
|
71
102
|
|
|
72
|
-
await expect(vm.ls({ name:
|
|
73
|
-
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"]);
|
|
74
105
|
|
|
75
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
76
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
77
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
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"]);
|
|
78
109
|
});
|
|
79
110
|
|
|
80
|
-
it(
|
|
81
|
-
execMocks.exec.mockImplementation(() => ({ ok: true, stdout:
|
|
82
|
-
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");
|
|
83
114
|
});
|
|
84
115
|
});
|
|
85
116
|
|
|
86
|
-
describe(
|
|
87
|
-
it(
|
|
88
|
-
const outputs = [inspectOutput,
|
|
117
|
+
describe("rm", () => {
|
|
118
|
+
it("should remove files of a volume by path", async () => {
|
|
119
|
+
const outputs = [inspectOutput, ""];
|
|
89
120
|
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: outputs.shift() }));
|
|
90
121
|
|
|
91
|
-
await expect(vm.rm({ name:
|
|
122
|
+
await expect(vm.rm({ name: "test", path: "file.txt" })).resolves.toEqual(true);
|
|
92
123
|
|
|
93
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
94
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
124
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
|
|
125
|
+
expect(execMocks.exec).toHaveBeenCalledWith("rm", ["-r", "/var/lib/docker/volumes/test/_data/file.txt"]);
|
|
95
126
|
});
|
|
96
127
|
|
|
97
|
-
it(
|
|
98
|
-
execMocks.exec.mockImplementation(() => ({ ok: true, stdout:
|
|
99
|
-
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");
|
|
100
131
|
});
|
|
101
132
|
|
|
102
|
-
it(
|
|
133
|
+
it("should throw an error if path is invalid", async () => {
|
|
103
134
|
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
|
|
104
|
-
await expect(vm.rm({ name:
|
|
135
|
+
await expect(vm.rm({ name: "nopath" })).rejects.toThrowError("Path not specified");
|
|
105
136
|
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
106
137
|
});
|
|
107
138
|
});
|
|
108
139
|
|
|
109
|
-
describe(
|
|
110
|
-
it(
|
|
140
|
+
describe("cat", () => {
|
|
141
|
+
it("should read the content of a file in a volume by path", async () => {
|
|
111
142
|
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
|
|
112
|
-
execMocks.readFile.mockImplementation(() =>
|
|
143
|
+
execMocks.readFile.mockImplementation(() => "test file");
|
|
113
144
|
|
|
114
|
-
await expect(vm.cat({ name:
|
|
145
|
+
await expect(vm.cat({ name: "test", path: "file.txt" })).resolves.toEqual("test file");
|
|
115
146
|
|
|
116
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
117
|
-
expect(execMocks.readFile).toHaveBeenCalledWith(
|
|
147
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
|
|
148
|
+
expect(execMocks.readFile).toHaveBeenCalledWith("/var/lib/docker/volumes/test/_data/file.txt", "utf8");
|
|
118
149
|
});
|
|
119
150
|
|
|
120
|
-
it(
|
|
121
|
-
execMocks.exec.mockImplementation(() => ({ ok: true, stdout:
|
|
122
|
-
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");
|
|
123
154
|
});
|
|
124
155
|
|
|
125
|
-
it(
|
|
156
|
+
it("should throw an error if path is invalid", async () => {
|
|
126
157
|
execMocks.exec.mockImplementation(() => ({ ok: true, stdout: inspectOutput }));
|
|
127
|
-
await expect(vm.cat({ name:
|
|
158
|
+
await expect(vm.cat({ name: "nopath" })).rejects.toThrowError("Path not specified");
|
|
128
159
|
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
129
160
|
});
|
|
130
161
|
});
|
|
131
162
|
|
|
132
|
-
describe(
|
|
133
|
-
it(
|
|
163
|
+
describe("show", () => {
|
|
164
|
+
it("should show details of a volume", async () => {
|
|
134
165
|
const execOutput = { ok: true, stdout: inspectOutput };
|
|
135
166
|
execMocks.exec.mockResolvedValue(execOutput);
|
|
136
167
|
|
|
137
|
-
await expect(vm.show({ name:
|
|
138
|
-
name:
|
|
139
|
-
createdAt:
|
|
168
|
+
await expect(vm.show({ name: "test" })).resolves.toEqual({
|
|
169
|
+
name: "test",
|
|
170
|
+
createdAt: "2023-03-10T10:15:10Z",
|
|
140
171
|
labels: {
|
|
141
|
-
origin:
|
|
142
|
-
version:
|
|
172
|
+
origin: "test/container",
|
|
173
|
+
version: "534524",
|
|
143
174
|
},
|
|
144
175
|
});
|
|
145
176
|
|
|
146
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
177
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "inspect", "test"]);
|
|
147
178
|
});
|
|
148
179
|
|
|
149
|
-
it(
|
|
150
|
-
await expect(vm.show({ name:
|
|
180
|
+
it("should throw an error if volume name is invalid", async () => {
|
|
181
|
+
await expect(vm.show({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
|
|
151
182
|
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
152
183
|
});
|
|
153
184
|
|
|
154
|
-
it(
|
|
155
|
-
const output = { ok: true, stdout:
|
|
185
|
+
it("should throw error if volume does not exist", async () => {
|
|
186
|
+
const output = { ok: true, stdout: "[]" };
|
|
156
187
|
execMocks.exec.mockResolvedValue(output);
|
|
157
188
|
|
|
158
|
-
await expect(vm.show({ name:
|
|
189
|
+
await expect(vm.show({ name: "invalid" })).rejects.toThrowError("Volume not found");
|
|
159
190
|
});
|
|
160
191
|
});
|
|
161
192
|
|
|
162
|
-
describe(
|
|
163
|
-
it(
|
|
164
|
-
|
|
165
|
-
|
|
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: "" });
|
|
166
199
|
|
|
167
|
-
await expect(vm.add({ name:
|
|
200
|
+
await expect(vm.add({ name: "test" })).resolves.toEqual(true);
|
|
168
201
|
|
|
169
|
-
expect(execMocks.exec).toHaveBeenCalledWith(
|
|
202
|
+
expect(execMocks.exec).toHaveBeenCalledWith("docker", ["volume", "create", "test"]);
|
|
170
203
|
});
|
|
171
204
|
|
|
172
|
-
it(
|
|
173
|
-
await expect(vm.add({ name:
|
|
205
|
+
it("should throw error if volume name is invalid", async () => {
|
|
206
|
+
await expect(vm.add({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
|
|
174
207
|
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
175
208
|
});
|
|
176
209
|
|
|
177
|
-
it(
|
|
178
|
-
const output = { ok: false, stdout:
|
|
210
|
+
it("should throw error if volume creation fails", async () => {
|
|
211
|
+
const output = { ok: false, stdout: "" };
|
|
179
212
|
execMocks.exec.mockResolvedValue(output);
|
|
180
213
|
|
|
181
|
-
await expect(vm.add({ name:
|
|
214
|
+
await expect(vm.add({ name: "failed" })).rejects.toThrowError("Unable to create volume");
|
|
182
215
|
});
|
|
183
216
|
});
|
|
184
217
|
|
|
185
|
-
describe(
|
|
186
|
-
it(
|
|
187
|
-
const execOutput = { ok: true, stdout:
|
|
218
|
+
describe("remove", () => {
|
|
219
|
+
it("should add a volume", async () => {
|
|
220
|
+
const execOutput = { ok: true, stdout: "" };
|
|
188
221
|
execMocks.exec.mockResolvedValue(execOutput);
|
|
189
222
|
|
|
190
|
-
await expect(vm.remove({ name:
|
|
191
|
-
|
|
192
|
-
expect(execMocks.exec).toHaveBeenCalledWith('docker', ['volume', 'rm', 'test']);
|
|
223
|
+
await expect(vm.remove({ name: "test" })).resolves.toEqual(true);
|
|
193
224
|
});
|
|
194
225
|
|
|
195
|
-
it(
|
|
196
|
-
await expect(vm.remove({ name:
|
|
226
|
+
it("should throw error if volume name is invalid", async () => {
|
|
227
|
+
await expect(vm.remove({ name: "In$%valid" })).rejects.toThrowError("Invalid name");
|
|
197
228
|
expect(execMocks.exec).not.toHaveBeenCalled();
|
|
198
229
|
});
|
|
199
230
|
|
|
200
|
-
it(
|
|
201
|
-
const output = { ok: false, stdout:
|
|
231
|
+
it("should throw error if volume removal fails", async () => {
|
|
232
|
+
const output = { ok: false, stdout: "" };
|
|
202
233
|
execMocks.exec.mockResolvedValue(output);
|
|
203
234
|
|
|
204
|
-
await expect(vm.remove({ name:
|
|
235
|
+
await expect(vm.remove({ name: "failed" })).rejects.toThrowError("Unable to remove volume");
|
|
205
236
|
});
|
|
206
237
|
});
|
|
207
238
|
|
|
208
|
-
describe(
|
|
209
|
-
it(
|
|
210
|
-
const execOutput = { ok: true, stdout:
|
|
239
|
+
describe("prune", () => {
|
|
240
|
+
it("should remove ununsed volumes", async () => {
|
|
241
|
+
const execOutput = { ok: true, stdout: "" };
|
|
211
242
|
execMocks.exec.mockResolvedValue(execOutput);
|
|
212
243
|
|
|
213
|
-
await expect(vm.prune()).resolves.toEqual(
|
|
214
|
-
|
|
215
|
-
expect(execMocks.exec).toHaveBeenCalledWith('docker', ['volume', 'prune']);
|
|
244
|
+
await expect(vm.prune()).resolves.toEqual("");
|
|
216
245
|
});
|
|
217
246
|
});
|
|
218
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
|
+
};
|