cdd-cli 3.1.9 → 3.2.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/CHANGELOG.md +17 -0
- package/coverage/clover.xml +604 -0
- package/coverage/coverage-final.json +17 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/helpers/constants.js.html +235 -0
- package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +211 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +853 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +289 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +131 -0
- package/coverage/lcov-report/helpers/index.html +176 -0
- package/coverage/lcov-report/helpers/logger.js.html +424 -0
- package/coverage/lcov-report/helpers/safeCall.js.html +139 -0
- package/coverage/lcov-report/helpers/validationHelpers.js.html +394 -0
- package/coverage/lcov-report/hooks/creation/index.html +146 -0
- package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +292 -0
- package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +478 -0
- package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +238 -0
- package/coverage/lcov-report/hooks/debug/index.html +116 -0
- package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +172 -0
- package/coverage/lcov-report/hooks/index.html +161 -0
- package/coverage/lcov-report/hooks/navigation/index.html +116 -0
- package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +160 -0
- package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +415 -0
- package/coverage/lcov-report/hooks/useControls.js.html +685 -0
- package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +202 -0
- package/coverage/lcov-report/hooks/useExitHandler.js.html +283 -0
- package/coverage/lcov-report/index.html +191 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +1216 -0
- package/package.json +1 -1
- package/src/helpers/constants.js +20 -0
- package/src/helpers/dockerService/serviceComponents/containerActions.js +55 -8
- package/src/helpers/validationHelpers.js +75 -18
- package/src/hooks/creation/useContainerCreation.js +19 -4
- package/src/hooks/useControls.js +8 -3
- package/test/containerActions.test.js +104 -13
- package/test/containerOptionsBuilder.test.js +36 -0
- package/test/useContainerCreation.dom.test.js +43 -2
- package/test/useControls.dom.test.js +117 -0
- package/test/validationHelpers.test.js +51 -0
package/package.json
CHANGED
package/src/helpers/constants.js
CHANGED
|
@@ -28,3 +28,23 @@ export const TIMEOUTS = {
|
|
|
28
28
|
CONTAINER_OP: 30000,
|
|
29
29
|
PULL_IMAGE: 300000,
|
|
30
30
|
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* List of well-known database image names (without tags or registry prefixes).
|
|
34
|
+
*/
|
|
35
|
+
export const DB_IMAGES = ["mysql", "mariadb", "postgres", "mongo", "mssql", "redis"];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Per-image profiles with required env vars and default exposed port.
|
|
39
|
+
* Keys are normalized image names (no tag, no registry prefix).
|
|
40
|
+
*
|
|
41
|
+
* @type {Record<string, { requiredEnv: string[], defaultPort: string }>}
|
|
42
|
+
*/
|
|
43
|
+
export const IMAGE_PROFILES = {
|
|
44
|
+
mysql: { requiredEnv: ["MYSQL_ROOT_PASSWORD"], defaultPort: "3306" },
|
|
45
|
+
mariadb: { requiredEnv: ["MARIADB_ROOT_PASSWORD"], defaultPort: "3306" },
|
|
46
|
+
postgres: { requiredEnv: ["POSTGRES_PASSWORD"], defaultPort: "5432" },
|
|
47
|
+
mongo: { requiredEnv: [], defaultPort: "27017" },
|
|
48
|
+
mssql: { requiredEnv: ["ACCEPT_EULA", "SA_PASSWORD"], defaultPort: "1433" },
|
|
49
|
+
redis: { requiredEnv: [], defaultPort: "6379" },
|
|
50
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { docker } from "../dockerService.js";
|
|
2
2
|
import { imageExists, pullImage } from "./imageUtils.js";
|
|
3
|
-
import { TIMEOUTS } from "../../constants.js";
|
|
3
|
+
import { TIMEOUTS, IMAGE_PROFILES } from "../../constants.js";
|
|
4
4
|
import { logger } from "../../logger.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -40,15 +40,33 @@ export async function removeContainer(containerId) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Normalize image name to base name (strips registry prefix and tag).
|
|
45
|
+
* e.g. "docker.io/library/postgres:16" → "postgres"
|
|
46
|
+
*
|
|
47
|
+
* @param {string} imageName - Raw image name
|
|
48
|
+
* @returns {string} Base name (lowercase)
|
|
49
|
+
*/
|
|
50
|
+
function normalizeImageName(imageName) {
|
|
51
|
+
if (!imageName) return "";
|
|
52
|
+
let name = imageName;
|
|
53
|
+
const slashIdx = name.lastIndexOf("/");
|
|
54
|
+
if (slashIdx !== -1) name = name.slice(slashIdx + 1);
|
|
55
|
+
const colonIdx = name.indexOf(":");
|
|
56
|
+
if (colonIdx !== -1) name = name.slice(0, colonIdx);
|
|
57
|
+
return name.toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/**
|
|
44
61
|
* Create a new container from an image. If the image is missing locally, it will be pulled.
|
|
45
62
|
*
|
|
46
63
|
* @param {string} imageName - Image name (e.g. 'nginx:alpine')
|
|
47
64
|
* @param {Object} [options] - Docker create options (Env, ExposedPorts, HostConfig, name, etc.)
|
|
48
|
-
* @
|
|
65
|
+
* @param {Object} [imageProfiles] - Image profiles map for defaultPort fallback
|
|
66
|
+
* @returns {Promise<{id: string, ports: Array<{containerPort: string, hostPort: string, protocol: string, source: string}>}>}
|
|
49
67
|
* @throws {Error} If image listing/pull or creation fails
|
|
50
68
|
*/
|
|
51
|
-
export async function createContainer(imageName, options = {}) {
|
|
69
|
+
export async function createContainer(imageName, options = {}, imageProfiles = IMAGE_PROFILES) {
|
|
52
70
|
logger.info("Creating container from image %s", imageName);
|
|
53
71
|
let exists;
|
|
54
72
|
try {
|
|
@@ -75,6 +93,9 @@ export async function createContainer(imageName, options = {}) {
|
|
|
75
93
|
|
|
76
94
|
const hasUserDefinedPorts = Boolean(createOpts.ExposedPorts && Object.keys(createOpts.ExposedPorts).length);
|
|
77
95
|
|
|
96
|
+
/** @type {Array<{containerPort: string, hostPort: string, protocol: string, source: string}>} */
|
|
97
|
+
const assignedPorts = [];
|
|
98
|
+
|
|
78
99
|
if (!hasUserDefinedPorts) {
|
|
79
100
|
try {
|
|
80
101
|
const image = docker.getImage(imageName);
|
|
@@ -101,7 +122,6 @@ export async function createContainer(imageName, options = {}) {
|
|
|
101
122
|
const pickNextAvailablePort = (base) => {
|
|
102
123
|
const numericBase = Number.parseInt(base, 10);
|
|
103
124
|
if (Number.isNaN(numericBase)) {
|
|
104
|
-
// If the base port is non-numeric, just return the base.
|
|
105
125
|
if (!usedHostPorts.has(base)) {
|
|
106
126
|
usedHostPorts.add(base);
|
|
107
127
|
return base;
|
|
@@ -124,8 +144,20 @@ export async function createContainer(imageName, options = {}) {
|
|
|
124
144
|
};
|
|
125
145
|
|
|
126
146
|
const inspectData = await withTimeout(image.inspect(), TIMEOUTS.CONTAINER_OP);
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
let exposed = inspectData?.Config?.ExposedPorts || inspectData?.ContainerConfig?.ExposedPorts || {};
|
|
148
|
+
let exposedKeys = Object.keys(exposed || {});
|
|
149
|
+
|
|
150
|
+
// If no ExposedPorts in image, fall back to IMAGE_PROFILES defaultPort
|
|
151
|
+
if (!exposedKeys.length) {
|
|
152
|
+
const baseName = normalizeImageName(imageName);
|
|
153
|
+
const profile = imageProfiles[baseName];
|
|
154
|
+
if (profile && profile.defaultPort) {
|
|
155
|
+
const fallbackKey = `${profile.defaultPort}/tcp`;
|
|
156
|
+
exposed = { [fallbackKey]: {} };
|
|
157
|
+
exposedKeys = [fallbackKey];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
129
161
|
if (exposedKeys.length) {
|
|
130
162
|
createOpts.ExposedPorts = createOpts.ExposedPorts || {};
|
|
131
163
|
createOpts.HostConfig = { ...(createOpts.HostConfig || {}) };
|
|
@@ -135,9 +167,12 @@ export async function createContainer(imageName, options = {}) {
|
|
|
135
167
|
createOpts.ExposedPorts[portKey] = exposed[portKey] || {};
|
|
136
168
|
}
|
|
137
169
|
if (!createOpts.HostConfig.PortBindings[portKey] || !createOpts.HostConfig.PortBindings[portKey].length) {
|
|
138
|
-
const
|
|
170
|
+
const parts = portKey.split("/");
|
|
171
|
+
const containerPort = parts[0];
|
|
172
|
+
const protocol = parts[1] || "tcp";
|
|
139
173
|
const hostPort = pickNextAvailablePort(containerPort);
|
|
140
174
|
createOpts.HostConfig.PortBindings[portKey] = [{ HostPort: hostPort }];
|
|
175
|
+
assignedPorts.push({ containerPort, hostPort, protocol, source: "auto" });
|
|
141
176
|
}
|
|
142
177
|
});
|
|
143
178
|
}
|
|
@@ -145,12 +180,24 @@ export async function createContainer(imageName, options = {}) {
|
|
|
145
180
|
} catch (err) {
|
|
146
181
|
logger.warn("Could not inspect image %s for default ports", imageName, err);
|
|
147
182
|
}
|
|
183
|
+
} else {
|
|
184
|
+
// User-defined ports: collect them with source: "user"
|
|
185
|
+
const portBindings = createOpts.HostConfig?.PortBindings || {};
|
|
186
|
+
Object.entries(portBindings).forEach(([portKey, bindings]) => {
|
|
187
|
+
const parts = portKey.split("/");
|
|
188
|
+
const containerPort = parts[0];
|
|
189
|
+
const protocol = parts[1] || "tcp";
|
|
190
|
+
(bindings || []).forEach((b) => {
|
|
191
|
+
assignedPorts.push({ containerPort, hostPort: b.HostPort, protocol, source: "user" });
|
|
192
|
+
});
|
|
193
|
+
});
|
|
148
194
|
}
|
|
195
|
+
|
|
149
196
|
try {
|
|
150
197
|
const container = await withTimeout(docker.createContainer(createOpts), TIMEOUTS.CONTAINER_OP);
|
|
151
198
|
const containerId = container.id || container.Id;
|
|
152
199
|
logger.info("Created container %s from image %s", containerId, imageName);
|
|
153
|
-
return
|
|
200
|
+
return { id: containerId, ports: assignedPorts };
|
|
154
201
|
} catch (err) {
|
|
155
202
|
logger.error("Failed to create container from image %s", imageName, err);
|
|
156
203
|
throw new Error('Error creating container: ' + err.message);
|
|
@@ -19,28 +19,85 @@ export function validatePorts(portInput) {
|
|
|
19
19
|
return !invalid;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Normalize an image name to its base name (strips registry prefix and tag).
|
|
24
|
+
* e.g. "docker.io/library/postgres:16-alpine" → "postgres"
|
|
25
|
+
*
|
|
26
|
+
* @param {string} imageName - Raw image name from user
|
|
27
|
+
* @returns {string} Base image name (lowercase)
|
|
28
|
+
*/
|
|
29
|
+
function normalizeImageName(imageName) {
|
|
30
|
+
if (!imageName) return "";
|
|
31
|
+
// Remove registry prefix (anything before the last / when it contains a dot or colon before the /)
|
|
32
|
+
let name = imageName;
|
|
33
|
+
const slashIdx = name.lastIndexOf("/");
|
|
34
|
+
if (slashIdx !== -1) {
|
|
35
|
+
name = name.slice(slashIdx + 1);
|
|
36
|
+
}
|
|
37
|
+
// Remove tag
|
|
38
|
+
const colonIdx = name.indexOf(":");
|
|
39
|
+
if (colonIdx !== -1) {
|
|
40
|
+
name = name.slice(0, colonIdx);
|
|
41
|
+
}
|
|
42
|
+
return name.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
/**
|
|
23
46
|
* Validate environment variable input as comma-separated VAR=value pairs.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
47
|
+
*
|
|
48
|
+
* When called with 3 arguments (envInput, imageName, imageProfiles), performs
|
|
49
|
+
* contextual validation: checks that all required env vars for the given image
|
|
50
|
+
* are present and non-empty. Returns an object { valid, errors, parsedEnv }.
|
|
51
|
+
*
|
|
52
|
+
* When called with 1 argument (legacy), returns a boolean for backward compatibility.
|
|
27
53
|
*
|
|
28
54
|
* @param {string} envInput - Comma-separated environment variable assignments
|
|
29
|
-
* @
|
|
55
|
+
* @param {string} [imageName] - Optional Docker image name for contextual validation
|
|
56
|
+
* @param {Object} [imageProfiles] - Optional map of image profiles (from constants.js)
|
|
57
|
+
* @returns {boolean|{valid: boolean, errors: string[], parsedEnv: Record<string,string>}}
|
|
30
58
|
*/
|
|
31
|
-
export function validateEnvVars(envInput) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const vars = envInput.split(",").map(v => v.trim()).filter(Boolean);
|
|
35
|
-
const invalid = vars.find(v => {
|
|
36
|
-
const parts = v.split("=");
|
|
37
|
-
// Must have at least VAR=value format
|
|
38
|
-
if (parts.length < 2) return true;
|
|
39
|
-
const varName = parts[0].trim();
|
|
40
|
-
// Variable names should be alphanumeric with underscores
|
|
41
|
-
if (!/^[A-Z_][A-Z0-9_]*$/i.test(varName)) return true;
|
|
42
|
-
return false;
|
|
43
|
-
});
|
|
59
|
+
export function validateEnvVars(envInput, imageName, imageProfiles) {
|
|
60
|
+
const contextual = imageName !== undefined && imageProfiles !== undefined;
|
|
44
61
|
|
|
45
|
-
|
|
62
|
+
// Parse: split on FIRST '=' only
|
|
63
|
+
const parsedEnv = {};
|
|
64
|
+
const syntaxErrors = [];
|
|
65
|
+
|
|
66
|
+
if (envInput && envInput.trim()) {
|
|
67
|
+
const vars = envInput.split(",").map(v => v.trim()).filter(Boolean);
|
|
68
|
+
for (const v of vars) {
|
|
69
|
+
const eqIdx = v.indexOf("=");
|
|
70
|
+
if (eqIdx === -1) {
|
|
71
|
+
syntaxErrors.push(`"${v}" is missing an '=' sign`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const varName = v.slice(0, eqIdx).trim();
|
|
75
|
+
const varValue = v.slice(eqIdx + 1);
|
|
76
|
+
if (!/^[A-Z_][A-Z0-9_]*$/i.test(varName)) {
|
|
77
|
+
syntaxErrors.push(`"${varName}" is not a valid variable name`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
parsedEnv[varName] = varValue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!contextual) {
|
|
85
|
+
// Legacy: return boolean
|
|
86
|
+
return syntaxErrors.length === 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Contextual: also check required env vars from the profile
|
|
90
|
+
const errors = [...syntaxErrors];
|
|
91
|
+
const baseName = normalizeImageName(imageName);
|
|
92
|
+
const profile = imageProfiles[baseName];
|
|
93
|
+
|
|
94
|
+
if (profile && profile.requiredEnv && profile.requiredEnv.length) {
|
|
95
|
+
for (const required of profile.requiredEnv) {
|
|
96
|
+
if (!parsedEnv[required] || parsedEnv[required].trim() === "") {
|
|
97
|
+
errors.push(`Missing required env var: ${required}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { valid: errors.length === 0, errors, parsedEnv };
|
|
46
103
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import { validatePorts } from "../../helpers/validationHelpers.js";
|
|
2
|
+
import { validatePorts, validateEnvVars } from "../../helpers/validationHelpers.js";
|
|
3
|
+
import { IMAGE_PROFILES } from "../../helpers/constants.js";
|
|
3
4
|
import { safeCall } from "../../helpers/safeCall.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -9,10 +10,11 @@ import { safeCall } from "../../helpers/safeCall.js";
|
|
|
9
10
|
* @param {Object} params
|
|
10
11
|
* @param {Function} params.onCreate - Callback when creation is confirmed
|
|
11
12
|
* @param {Function} params.onCancel - Callback when creation is cancelled
|
|
12
|
-
* @param {Array<string>} params.dbImages - List of DB image names for env var warning
|
|
13
|
+
* @param {Array<string>} params.dbImages - List of DB image names for env var warning (legacy)
|
|
14
|
+
* @param {Object} [params.imageProfiles] - Image profiles map for contextual validation
|
|
13
15
|
* @returns {Object} Creation state, setters, and helpers
|
|
14
16
|
*/
|
|
15
|
-
export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
|
|
17
|
+
export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageProfiles = IMAGE_PROFILES }) {
|
|
16
18
|
const [step, setStep] = useState(0); // 0: image, 1: name, 2: ports, 3: env
|
|
17
19
|
const [imageName, setImageName] = useState("");
|
|
18
20
|
const [containerName, setContainerName] = useState("");
|
|
@@ -51,7 +53,13 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
|
|
|
51
53
|
}
|
|
52
54
|
setStep(3);
|
|
53
55
|
const isDb = dbImages.some(db => imageName.trim().toLowerCase().includes(db));
|
|
54
|
-
if
|
|
56
|
+
// Check if the image has a profile with required env vars
|
|
57
|
+
const baseName = imageName.trim().toLowerCase().split(":")[0].split("/").pop();
|
|
58
|
+
const profile = imageProfiles[baseName];
|
|
59
|
+
if (profile && profile.requiredEnv && profile.requiredEnv.length) {
|
|
60
|
+
setMessage(`Required env vars for ${baseName}: ${profile.requiredEnv.join(", ")}. Enter as VAR=val,VAR2=val2`);
|
|
61
|
+
setMessageColor("yellow");
|
|
62
|
+
} else if (isDb) {
|
|
55
63
|
setMessage("Warning: This image usually requires environment variables (e.g. MYSQL_ROOT_PASSWORD=my-secret-pw for MySQL, POSTGRES_PASSWORD=yourpassword for Postgres). Enter them as VAR=val,VAR2=val2 or leave empty and press Enter.");
|
|
56
64
|
setMessageColor("yellow");
|
|
57
65
|
} else {
|
|
@@ -61,6 +69,13 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
|
|
|
61
69
|
return;
|
|
62
70
|
}
|
|
63
71
|
if (step === 3) {
|
|
72
|
+
// Contextual env validation using image profiles
|
|
73
|
+
const result = validateEnvVars(envInput, imageName, imageProfiles);
|
|
74
|
+
if (!result.valid) {
|
|
75
|
+
setMessage(result.errors.join(" | "));
|
|
76
|
+
setMessageColor("red");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
64
79
|
// Final step: call onCreate with all data (safely)
|
|
65
80
|
safeCall(onCreate, { imageName, containerName, portInput, envInput });
|
|
66
81
|
}
|
package/src/hooks/useControls.js
CHANGED
|
@@ -11,6 +11,7 @@ import { useContainerCommandRouter } from "./useContainerCommandRouter";
|
|
|
11
11
|
import { getLogsStream } from "../helpers/dockerService/serviceComponents/containerLogs.js";
|
|
12
12
|
import { createContainer as svcCreateContainer } from "../helpers/dockerService/serviceComponents/containerActions.js";
|
|
13
13
|
import { buildContainerOptions } from "../helpers/containerOptionsBuilder.js";
|
|
14
|
+
import { DB_IMAGES } from "../helpers/constants.js";
|
|
14
15
|
|
|
15
16
|
// Principal hook to manage user inputs and control the app state
|
|
16
17
|
/**
|
|
@@ -32,8 +33,12 @@ export function useControls(containers = []) {
|
|
|
32
33
|
actions.setMessage(`Creating container ${imageName}...`);
|
|
33
34
|
actions.setMessageColor("yellow");
|
|
34
35
|
try {
|
|
35
|
-
const id = await svcCreateContainer(imageName, options);
|
|
36
|
-
|
|
36
|
+
const { id, ports } = await svcCreateContainer(imageName, options);
|
|
37
|
+
let portMsg = "";
|
|
38
|
+
if (ports && ports.length) {
|
|
39
|
+
portMsg = " | Ports: " + ports.map(p => `${p.hostPort}→${p.containerPort}/${p.protocol}`).join(", ");
|
|
40
|
+
}
|
|
41
|
+
actions.setMessage(`Created container ${id}${portMsg}`);
|
|
37
42
|
actions.setMessageColor("green");
|
|
38
43
|
} catch (err) {
|
|
39
44
|
actions.setMessage(`Error creating container: ${err.message}`);
|
|
@@ -43,7 +48,7 @@ export function useControls(containers = []) {
|
|
|
43
48
|
}
|
|
44
49
|
},
|
|
45
50
|
onCancel: () => setCreatingContainer(false),
|
|
46
|
-
dbImages:
|
|
51
|
+
dbImages: DB_IMAGES,
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
const logsViewer = useLogsViewer();
|
|
@@ -3,14 +3,13 @@ import { jest } from '@jest/globals';
|
|
|
3
3
|
describe('containerActions service functions (mocked ESM imports)', () => {
|
|
4
4
|
afterEach(() => jest.resetModules());
|
|
5
5
|
|
|
6
|
-
test('createContainer returns id when image exists', async () => {
|
|
7
|
-
// mock imageUtils and dockerService before importing the module under test
|
|
6
|
+
test('createContainer returns { id, ports } when image exists', async () => {
|
|
8
7
|
const imageUtilsMock = {
|
|
9
8
|
imageExists: jest.fn().mockResolvedValue(true),
|
|
10
9
|
pullImage: jest.fn()
|
|
11
10
|
};
|
|
12
11
|
|
|
13
|
-
const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
|
|
12
|
+
const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: { '3306/tcp': {} } } });
|
|
14
13
|
const dockerMock = {
|
|
15
14
|
createContainer: jest.fn().mockResolvedValue({ id: 'cid-123' }),
|
|
16
15
|
getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
|
|
@@ -27,16 +26,107 @@ describe('containerActions service functions (mocked ESM imports)', () => {
|
|
|
27
26
|
|
|
28
27
|
const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
|
|
29
28
|
|
|
30
|
-
const
|
|
31
|
-
expect(
|
|
32
|
-
|
|
33
|
-
expect(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
const result = await mod.createContainer('mysql:8', { Name: 'test' });
|
|
30
|
+
expect(result).toHaveProperty('id', 'cid-123');
|
|
31
|
+
expect(result).toHaveProperty('ports');
|
|
32
|
+
expect(Array.isArray(result.ports)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('createContainer ports contain containerPort, hostPort, protocol, source fields', async () => {
|
|
36
|
+
const imageUtilsMock = {
|
|
37
|
+
imageExists: jest.fn().mockResolvedValue(true),
|
|
38
|
+
pullImage: jest.fn()
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: { '80/tcp': {} } } });
|
|
42
|
+
const dockerMock = {
|
|
43
|
+
createContainer: jest.fn().mockResolvedValue({ id: 'cid-ports' }),
|
|
44
|
+
getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
|
|
45
|
+
listContainers: jest.fn().mockResolvedValue([])
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
|
|
49
|
+
...imageUtilsMock
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
|
|
53
|
+
docker: dockerMock
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
|
|
57
|
+
const result = await mod.createContainer('nginx:latest', {});
|
|
58
|
+
expect(result.ports.length).toBeGreaterThan(0);
|
|
59
|
+
const port = result.ports[0];
|
|
60
|
+
expect(port).toHaveProperty('containerPort');
|
|
61
|
+
expect(port).toHaveProperty('hostPort');
|
|
62
|
+
expect(port).toHaveProperty('protocol');
|
|
63
|
+
expect(port).toHaveProperty('source');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('createContainer uses IMAGE_PROFILES defaultPort as fallback when ExposedPorts empty', async () => {
|
|
67
|
+
const imageUtilsMock = {
|
|
68
|
+
imageExists: jest.fn().mockResolvedValue(true),
|
|
69
|
+
pullImage: jest.fn()
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// inspect returns empty ExposedPorts
|
|
73
|
+
const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
|
|
74
|
+
const dockerMock = {
|
|
75
|
+
createContainer: jest.fn().mockResolvedValue({ id: 'cid-fallback' }),
|
|
76
|
+
getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
|
|
77
|
+
listContainers: jest.fn().mockResolvedValue([])
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
|
|
81
|
+
...imageUtilsMock
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
|
|
85
|
+
docker: dockerMock
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const { IMAGE_PROFILES } = await import('../src/helpers/constants.js');
|
|
89
|
+
const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
|
|
90
|
+
const result = await mod.createContainer('mysql:8', {}, IMAGE_PROFILES);
|
|
91
|
+
|
|
92
|
+
expect(result.id).toBe('cid-fallback');
|
|
93
|
+
// Should have auto port from defaultPort fallback
|
|
94
|
+
const mysqlDefault = IMAGE_PROFILES.mysql.defaultPort;
|
|
95
|
+
const portEntry = result.ports.find(p => p.containerPort === mysqlDefault);
|
|
96
|
+
expect(portEntry).toBeDefined();
|
|
97
|
+
expect(portEntry.source).toBe('auto');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('createContainer returns { id, ports: [] } when no ports and image not in profiles', async () => {
|
|
101
|
+
const imageUtilsMock = {
|
|
102
|
+
imageExists: jest.fn().mockResolvedValue(true),
|
|
103
|
+
pullImage: jest.fn()
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
|
|
107
|
+
const dockerMock = {
|
|
108
|
+
createContainer: jest.fn().mockResolvedValue({ id: 'cid-noport' }),
|
|
109
|
+
getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
|
|
110
|
+
listContainers: jest.fn().mockResolvedValue([])
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
|
|
114
|
+
...imageUtilsMock
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
|
|
118
|
+
docker: dockerMock
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
const { IMAGE_PROFILES } = await import('../src/helpers/constants.js');
|
|
122
|
+
const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
|
|
123
|
+
const result = await mod.createContainer('unknownimage:latest', {}, IMAGE_PROFILES);
|
|
124
|
+
|
|
125
|
+
expect(result.id).toBe('cid-noport');
|
|
126
|
+
expect(result.ports).toEqual([]);
|
|
37
127
|
});
|
|
38
128
|
|
|
39
|
-
test('createContainer pulls image when not present and returns
|
|
129
|
+
test('createContainer pulls image when not present and returns { id, ports }', async () => {
|
|
40
130
|
const imageUtilsMock = {
|
|
41
131
|
imageExists: jest.fn().mockResolvedValue(false),
|
|
42
132
|
pullImage: jest.fn().mockResolvedValue(true)
|
|
@@ -58,8 +148,9 @@ describe('containerActions service functions (mocked ESM imports)', () => {
|
|
|
58
148
|
}));
|
|
59
149
|
|
|
60
150
|
const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
|
|
61
|
-
const
|
|
62
|
-
expect(id).toBe('CID-456');
|
|
151
|
+
const result = await mod.createContainer('busybox:1.0');
|
|
152
|
+
expect(result.id).toBe('CID-456');
|
|
153
|
+
expect(result).toHaveProperty('ports');
|
|
63
154
|
const imageUtils = await import('../src/helpers/dockerService/serviceComponents/imageUtils.js');
|
|
64
155
|
expect(imageUtils.pullImage).toHaveBeenCalledWith('busybox:1.0');
|
|
65
156
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
let buildContainerOptions;
|
|
2
|
+
|
|
3
|
+
beforeAll(async () => {
|
|
4
|
+
const mod = await import('../src/helpers/containerOptionsBuilder.js');
|
|
5
|
+
buildContainerOptions = mod.buildContainerOptions;
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('buildContainerOptions', () => {
|
|
9
|
+
test('basic image only sets Tty', () => {
|
|
10
|
+
const opts = buildContainerOptions({ imageName: 'nginx' });
|
|
11
|
+
expect(opts.Tty).toBe(true);
|
|
12
|
+
expect(opts.Env).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('env var with value containing = is preserved intact', () => {
|
|
16
|
+
const opts = buildContainerOptions({ imageName: 'nginx', envInput: 'TOKEN=a=b' });
|
|
17
|
+
expect(opts.Env).toEqual(['TOKEN=a=b']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('multiple env vars with = in values are all preserved', () => {
|
|
21
|
+
const opts = buildContainerOptions({ imageName: 'nginx', envInput: 'TOKEN=a=b,DSN=user:pass@host/db?opt=1' });
|
|
22
|
+
expect(opts.Env).toContain('TOKEN=a=b');
|
|
23
|
+
expect(opts.Env).toContain('DSN=user:pass@host/db?opt=1');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('port input builds ExposedPorts and PortBindings', () => {
|
|
27
|
+
const opts = buildContainerOptions({ imageName: 'nginx', portInput: '8080:80' });
|
|
28
|
+
expect(opts.ExposedPorts).toEqual({ '80/tcp': {} });
|
|
29
|
+
expect(opts.HostConfig.PortBindings['80/tcp']).toEqual([{ HostPort: '8080' }]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('container name is included when provided', () => {
|
|
33
|
+
const opts = buildContainerOptions({ imageName: 'nginx', containerName: 'my-nginx' });
|
|
34
|
+
expect(opts.name).toBe('my-nginx');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
import React, { useEffect } from 'react';
|
|
5
5
|
import { render, act } from '@testing-library/react';
|
|
6
6
|
import { useContainerCreation } from '../src/hooks/creation/useContainerCreation.js';
|
|
7
|
+
import { IMAGE_PROFILES } from '../src/helpers/constants.js';
|
|
7
8
|
|
|
8
|
-
function HookTester({ onCreate, onCancel, dbImages, expose }) {
|
|
9
|
-
const hook = useContainerCreation({ onCreate, onCancel, dbImages });
|
|
9
|
+
function HookTester({ onCreate, onCancel, dbImages, imageProfiles, expose }) {
|
|
10
|
+
const hook = useContainerCreation({ onCreate, onCancel, dbImages, imageProfiles });
|
|
10
11
|
// keep exposing the latest hook values on every render
|
|
11
12
|
useEffect(() => {
|
|
12
13
|
if (expose) expose.current = hook;
|
|
@@ -97,4 +98,44 @@ describe('useContainerCreation (DOM render)', () => {
|
|
|
97
98
|
expect(created.length).toBe(1);
|
|
98
99
|
expect(created[0]).toEqual({ imageName: 'redis', containerName: '', portInput: '', envInput: 'FOO=bar' });
|
|
99
100
|
});
|
|
101
|
+
|
|
102
|
+
test('step 3 with mysql image: missing MYSQL_ROOT_PASSWORD blocks creation', () => {
|
|
103
|
+
const created = [];
|
|
104
|
+
const expose = { current: null };
|
|
105
|
+
|
|
106
|
+
render(<HookTester onCreate={(d) => created.push(d)} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
107
|
+
|
|
108
|
+
// advance to step 3 (env) with mysql image
|
|
109
|
+
act(() => { expose.current.setImageName('mysql:8'); });
|
|
110
|
+
act(() => { expose.current.nextStep(); }); // step 0 → 1
|
|
111
|
+
act(() => { expose.current.nextStep(); }); // step 1 → 2
|
|
112
|
+
act(() => { expose.current.nextStep(); }); // step 2 → 3
|
|
113
|
+
|
|
114
|
+
expect(expose.current.step).toBe(3);
|
|
115
|
+
|
|
116
|
+
// try to advance without required env var
|
|
117
|
+
act(() => { expose.current.nextStep(); });
|
|
118
|
+
|
|
119
|
+
expect(expose.current.step).toBe(3); // still blocked
|
|
120
|
+
expect(expose.current.message).toMatch(/MYSQL_ROOT_PASSWORD/i);
|
|
121
|
+
expect(created.length).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('step 3 with mysql image: valid env advances and calls onCreate', () => {
|
|
125
|
+
const created = [];
|
|
126
|
+
const expose = { current: null };
|
|
127
|
+
|
|
128
|
+
render(<HookTester onCreate={(d) => created.push(d)} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
129
|
+
|
|
130
|
+
act(() => { expose.current.setImageName('mysql:8'); });
|
|
131
|
+
act(() => { expose.current.nextStep(); });
|
|
132
|
+
act(() => { expose.current.nextStep(); });
|
|
133
|
+
act(() => { expose.current.nextStep(); }); // at step 3
|
|
134
|
+
|
|
135
|
+
act(() => { expose.current.setEnvInput('MYSQL_ROOT_PASSWORD=secret'); });
|
|
136
|
+
act(() => { expose.current.nextStep(); }); // should call onCreate
|
|
137
|
+
|
|
138
|
+
expect(created.length).toBe(1);
|
|
139
|
+
expect(created[0].imageName).toBe('mysql:8');
|
|
140
|
+
});
|
|
100
141
|
});
|