cdd-cli 3.1.9 → 3.2.2
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/.atl/skill-registry.md +30 -0
- package/.editorconfig +12 -0
- package/.eslintrc.json +1 -1
- package/.prettierignore +4 -0
- package/.prettierrc +8 -0
- package/CHANGELOG.md +46 -0
- package/SECURITY.md +5 -0
- package/__mocks__/ink.cjs +17 -0
- package/coverage/clover.xml +694 -0
- package/coverage/coverage-final.json +21 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +376 -0
- package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
- package/coverage/lcov-report/components/SuggestionPanel.jsx.html +244 -0
- package/coverage/lcov-report/components/index.html +146 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/helpers/constants.js.html +298 -0
- package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +244 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +931 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +307 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +131 -0
- package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
- package/coverage/lcov-report/helpers/index.html +191 -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 +346 -0
- package/coverage/lcov-report/hooks/creation/index.html +146 -0
- package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +313 -0
- package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +805 -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 +187 -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 +169 -0
- package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +454 -0
- package/coverage/lcov-report/hooks/useControls.js.html +826 -0
- package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +202 -0
- package/coverage/lcov-report/hooks/useExitHandler.js.html +292 -0
- package/coverage/lcov-report/index.html +206 -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 +1402 -0
- package/dist/App.js +11 -11
- package/dist/components/ContainerCreationPrompt.js +31 -10
- package/dist/components/ContainerList.js +3 -3
- package/dist/components/ContainerRow.js +20 -20
- package/dist/components/ContainerSection.js +3 -3
- package/dist/components/Footer.js +2 -2
- package/dist/components/Header.js +7 -7
- package/dist/components/LogViewer.js +3 -3
- package/dist/components/MessageFeedback.js +2 -2
- package/dist/components/PromptField.js +4 -4
- package/dist/components/StatsBar.js +5 -5
- package/dist/components/SuggestionPanel.js +44 -0
- package/dist/components/UsageMenu.js +3 -3
- package/dist/helpers/appInfo.js +7 -7
- package/dist/helpers/constants.js +114 -0
- package/dist/helpers/containerOptionsBuilder.js +3 -3
- package/dist/helpers/dockerService/dockerService.js +1 -1
- package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
- package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
- package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
- package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
- package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
- package/dist/helpers/exitWithMessage.js +10 -10
- package/dist/helpers/imageNameUtils.js +32 -0
- package/dist/helpers/logger.js +13 -13
- package/dist/helpers/safeCall.js +2 -2
- package/dist/helpers/validationHelpers.js +76 -21
- package/dist/hooks/creation/useContainerActions.js +9 -9
- package/dist/hooks/creation/useContainerCreation.js +138 -36
- package/dist/hooks/creation/useLogsViewer.js +2 -2
- package/dist/hooks/debug/useDebugLogs.js +4 -4
- package/dist/hooks/useContainerCommandRouter.js +13 -13
- package/dist/hooks/useContainers.js +3 -3
- package/dist/hooks/useControls.js +66 -35
- package/dist/hooks/useEraseConfirmation.js +3 -3
- package/dist/index.js +2 -2
- package/jest.config.cjs +7 -0
- package/package.json +7 -3
- package/src/App.jsx +33 -16
- package/src/components/ContainerCreationPrompt.jsx +43 -9
- package/src/components/ContainerList.jsx +3 -3
- package/src/components/ContainerRow.jsx +19 -16
- package/src/components/ContainerSection.jsx +3 -3
- package/src/components/Footer.jsx +2 -2
- package/src/components/Header.jsx +7 -7
- package/src/components/LogViewer.jsx +5 -3
- package/src/components/MessageFeedback.jsx +2 -2
- package/src/components/PromptField.jsx +4 -4
- package/src/components/StatsBar.jsx +11 -7
- package/src/components/SuggestionPanel.jsx +53 -0
- package/src/components/UsageMenu.jsx +18 -13
- package/src/helpers/appInfo.js +7 -7
- package/src/helpers/constants.js +41 -0
- package/src/helpers/containerOptionsBuilder.js +16 -5
- package/src/helpers/dockerService/dockerService.js +1 -1
- package/src/helpers/dockerService/serviceComponents/containerActions.js +116 -43
- package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
- package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
- package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
- package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
- package/src/helpers/exitWithMessage.js +13 -7
- package/src/helpers/imageNameUtils.js +32 -0
- package/src/helpers/logger.js +15 -15
- package/src/helpers/safeCall.js +2 -2
- package/src/helpers/validationHelpers.js +62 -21
- package/src/hooks/creation/useContainerActions.js +19 -12
- package/src/hooks/creation/useContainerCreation.js +161 -37
- package/src/hooks/creation/useLogsViewer.js +2 -2
- package/src/hooks/debug/useDebugLogs.js +15 -10
- package/src/hooks/navigation/useContainerSelection.js +17 -14
- package/src/hooks/useContainerCommandRouter.js +71 -58
- package/src/hooks/useContainers.js +4 -4
- package/src/hooks/useControls.js +120 -68
- package/src/hooks/useEraseConfirmation.js +3 -3
- package/src/hooks/useExitHandler.js +29 -26
- package/src/index.js +4 -4
- package/test/ContainerCreationPrompt.dom.test.js +68 -0
- package/test/SuggestionPanel.dom.test.js +53 -0
- package/test/constants.test.js +34 -0
- package/test/containerActions.test.js +104 -13
- package/test/containerOptionsBuilder.test.js +36 -0
- package/test/useContainerCreation.dom.test.js +166 -2
- package/test/useControls.dom.test.js +313 -0
- package/test/validationHelpers.test.js +51 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import React from
|
|
2
|
-
import { Text } from
|
|
3
|
-
import chalk from
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
4
|
import PropTypes from 'prop-types';
|
|
5
5
|
|
|
6
6
|
const BAR_WIDTH = 8;
|
|
@@ -19,7 +19,9 @@ export default function StatsBar({ cpu, mem }) {
|
|
|
19
19
|
|
|
20
20
|
return (
|
|
21
21
|
<Text>
|
|
22
|
-
CPU:{cpuBar}
|
|
22
|
+
CPU:{cpuBar}
|
|
23
|
+
{colorize(cpu, ` ${formatPercent(cpu)}%`)} MEM:{memBar}
|
|
24
|
+
{colorize(mem, ` ${formatPercent(mem)}%`)}
|
|
23
25
|
</Text>
|
|
24
26
|
);
|
|
25
27
|
}
|
|
@@ -38,10 +40,12 @@ StatsBar.propTypes = {
|
|
|
38
40
|
*/
|
|
39
41
|
function makeBar(value, width) {
|
|
40
42
|
const safeWidth = Math.max(1, Math.round(width));
|
|
41
|
-
const normalized = Number.isFinite(value)
|
|
43
|
+
const normalized = Number.isFinite(value)
|
|
44
|
+
? Math.min(Math.max(value, 0), 100)
|
|
45
|
+
: 0;
|
|
42
46
|
const filled = Math.round((normalized / 100) * safeWidth);
|
|
43
47
|
const empty = Math.max(0, safeWidth - filled);
|
|
44
|
-
const bar =
|
|
48
|
+
const bar = '█'.repeat(filled) + '░'.repeat(empty);
|
|
45
49
|
return colorize(normalized, bar);
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -49,7 +53,7 @@ function formatPercent(value) {
|
|
|
49
53
|
if (!Number.isFinite(value)) return 0;
|
|
50
54
|
const normalized = Math.min(Math.max(value, 0), 100);
|
|
51
55
|
if (normalized >= 10) return Math.round(normalized);
|
|
52
|
-
return normalized.toFixed(1).replace(/\.0$/,
|
|
56
|
+
return normalized.toFixed(1).replace(/\.0$/, '');
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/**
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders an autocomplete suggestion panel as a boxed list below the image input.
|
|
7
|
+
* Displays a window of `maxVisible` items starting at `visibleOffset`.
|
|
8
|
+
* The focused row (selectedIndex) is highlighted cyan with a › prefix.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} props
|
|
11
|
+
* @param {string[]} props.items - Full list of suggestion strings
|
|
12
|
+
* @param {number} props.selectedIndex - Currently focused index (-1 = none)
|
|
13
|
+
* @param {number} props.visibleOffset - First item index of the visible window
|
|
14
|
+
* @param {number} [props.maxVisible=6] - Maximum number of items visible at once
|
|
15
|
+
* @returns {JSX.Element|null}
|
|
16
|
+
*/
|
|
17
|
+
export function SuggestionPanel({
|
|
18
|
+
items,
|
|
19
|
+
selectedIndex,
|
|
20
|
+
visibleOffset,
|
|
21
|
+
maxVisible = 6,
|
|
22
|
+
}) {
|
|
23
|
+
if (!items || items.length === 0) return null;
|
|
24
|
+
|
|
25
|
+
const visible = items.slice(visibleOffset, visibleOffset + maxVisible);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Box
|
|
29
|
+
flexDirection="column"
|
|
30
|
+
borderStyle="single"
|
|
31
|
+
borderColor="cyan"
|
|
32
|
+
paddingX={1}
|
|
33
|
+
data-testid="suggestion-panel"
|
|
34
|
+
>
|
|
35
|
+
{visible.map((item, localIdx) => {
|
|
36
|
+
const globalIdx = visibleOffset + localIdx;
|
|
37
|
+
const isFocused = globalIdx === selectedIndex;
|
|
38
|
+
return (
|
|
39
|
+
<Text key={item} color={isFocused ? 'cyan' : undefined} dimColor={!isFocused}>
|
|
40
|
+
{isFocused ? `› ${item}` : ` ${item}`}
|
|
41
|
+
</Text>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</Box>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
SuggestionPanel.propTypes = {
|
|
49
|
+
items: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
50
|
+
selectedIndex: PropTypes.number.isRequired,
|
|
51
|
+
visibleOffset: PropTypes.number.isRequired,
|
|
52
|
+
maxVisible: PropTypes.number,
|
|
53
|
+
};
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import React from
|
|
2
|
-
import { Box, Text } from
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
3
|
|
|
4
4
|
const SHORTCUTS = [
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
'Use ↑/↓ for navigation',
|
|
6
|
+
'I to initiate selected container',
|
|
7
|
+
'P to stop selected container',
|
|
8
|
+
'R to restart selected container',
|
|
9
|
+
'C to create a container',
|
|
10
|
+
'L to view logs of selected container',
|
|
11
|
+
'D to toggle debug log panel',
|
|
12
|
+
'E to remove selected container',
|
|
13
|
+
'Q to quit',
|
|
14
14
|
];
|
|
15
15
|
|
|
16
16
|
const GRID_COLUMNS = 3;
|
|
@@ -24,7 +24,10 @@ const GRID_COLUMNS = 3;
|
|
|
24
24
|
export default function UsageMenu() {
|
|
25
25
|
const rowsPerColumn = Math.ceil(SHORTCUTS.length / GRID_COLUMNS);
|
|
26
26
|
const columns = Array.from({ length: GRID_COLUMNS }, (_, columnIndex) =>
|
|
27
|
-
SHORTCUTS.slice(
|
|
27
|
+
SHORTCUTS.slice(
|
|
28
|
+
columnIndex * rowsPerColumn,
|
|
29
|
+
(columnIndex + 1) * rowsPerColumn
|
|
30
|
+
)
|
|
28
31
|
).filter((column) => column.length > 0);
|
|
29
32
|
|
|
30
33
|
return (
|
|
@@ -37,7 +40,9 @@ export default function UsageMenu() {
|
|
|
37
40
|
marginRight={columnIndex === columns.length - 1 ? 0 : 3}
|
|
38
41
|
>
|
|
39
42
|
{column.map((shortcut, rowIndex) => (
|
|
40
|
-
<Text key={`shortcut-${columnIndex}-${rowIndex}`}>
|
|
43
|
+
<Text key={`shortcut-${columnIndex}-${rowIndex}`}>
|
|
44
|
+
{shortcut}
|
|
45
|
+
</Text>
|
|
41
46
|
))}
|
|
42
47
|
</Box>
|
|
43
48
|
))}
|
package/src/helpers/appInfo.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from
|
|
2
|
-
import path from
|
|
3
|
-
import { fileURLToPath } from
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
4
|
|
|
5
5
|
let cachedVersion;
|
|
6
6
|
|
|
@@ -12,12 +12,12 @@ export function getAppVersion() {
|
|
|
12
12
|
try {
|
|
13
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
14
|
const moduleDir = path.dirname(__filename);
|
|
15
|
-
const packageJsonPath = path.resolve(moduleDir,
|
|
16
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath,
|
|
17
|
-
cachedVersion = packageJson.version ??
|
|
15
|
+
const packageJsonPath = path.resolve(moduleDir, '../../package.json');
|
|
16
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
17
|
+
cachedVersion = packageJson.version ?? 'unknown';
|
|
18
18
|
return cachedVersion;
|
|
19
19
|
} catch (error) {
|
|
20
|
-
cachedVersion =
|
|
20
|
+
cachedVersion = 'unknown';
|
|
21
21
|
return cachedVersion;
|
|
22
22
|
}
|
|
23
23
|
}
|
package/src/helpers/constants.js
CHANGED
|
@@ -28,3 +28,44 @@ 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 = [
|
|
36
|
+
'mysql',
|
|
37
|
+
'mariadb',
|
|
38
|
+
'postgres',
|
|
39
|
+
'mongo',
|
|
40
|
+
'mssql',
|
|
41
|
+
'redis',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Per-image profiles with required env vars, default exposed port, and suggested env vars.
|
|
46
|
+
* Keys are normalized image names (no tag, no registry prefix).
|
|
47
|
+
*
|
|
48
|
+
* @type {Record<string, { requiredEnv: string[], defaultPort: string, suggestedEnv: string[] }>}
|
|
49
|
+
*/
|
|
50
|
+
export const IMAGE_PROFILES = {
|
|
51
|
+
nginx: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
|
|
52
|
+
httpd: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
|
|
53
|
+
caddy: { requiredEnv: [], defaultPort: '80', suggestedEnv: [] },
|
|
54
|
+
node: { requiredEnv: [], defaultPort: '3000', suggestedEnv: ['NODE_ENV=production'] },
|
|
55
|
+
python: { requiredEnv: [], defaultPort: '8000', suggestedEnv: ['PYTHONUNBUFFERED=1'] },
|
|
56
|
+
openjdk: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['JAVA_OPTS=-Xms256m -Xmx512m'] },
|
|
57
|
+
golang: { requiredEnv: [], defaultPort: '8080', suggestedEnv: ['GOMAXPROCS=2'] },
|
|
58
|
+
redis: { requiredEnv: [], defaultPort: '6379', suggestedEnv: ['REDIS_PASSWORD=change-me'] },
|
|
59
|
+
memcached: { requiredEnv: [], defaultPort: '11211', suggestedEnv: [] },
|
|
60
|
+
postgres: { requiredEnv: ['POSTGRES_PASSWORD'], defaultPort: '5432', suggestedEnv: ['POSTGRES_USER=postgres', 'POSTGRES_DB=app'] },
|
|
61
|
+
mysql: { requiredEnv: ['MYSQL_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MYSQL_DATABASE=app', 'MYSQL_USER=app', 'MYSQL_PASSWORD=app123'] },
|
|
62
|
+
mariadb: { requiredEnv: ['MARIADB_ROOT_PASSWORD'], defaultPort: '3306', suggestedEnv: ['MARIADB_DATABASE=app', 'MARIADB_USER=app', 'MARIADB_PASSWORD=app123'] },
|
|
63
|
+
mongo: { requiredEnv: [], defaultPort: '27017', suggestedEnv: ['MONGO_INITDB_ROOT_USERNAME=admin', 'MONGO_INITDB_ROOT_PASSWORD=secret'] },
|
|
64
|
+
mssql: { requiredEnv: ['ACCEPT_EULA', 'SA_PASSWORD'], defaultPort: '1433', suggestedEnv: ['ACCEPT_EULA=Y', 'MSSQL_PID=Developer'] },
|
|
65
|
+
rabbitmq: { requiredEnv: [], defaultPort: '5672', suggestedEnv: ['RABBITMQ_DEFAULT_USER=guest', 'RABBITMQ_DEFAULT_PASS=guest'] },
|
|
66
|
+
kafka: { requiredEnv: [], defaultPort: '9092', suggestedEnv: ['KAFKA_CFG_LISTENERS=PLAINTEXT://:9092', 'KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092'] },
|
|
67
|
+
zookeeper: { requiredEnv: [], defaultPort: '2181', suggestedEnv: ['ALLOW_ANONYMOUS_LOGIN=yes'] },
|
|
68
|
+
elasticsearch: { requiredEnv: [], defaultPort: '9200', suggestedEnv: ['discovery.type=single-node', 'xpack.security.enabled=false'] },
|
|
69
|
+
minio: { requiredEnv: ['MINIO_ROOT_USER', 'MINIO_ROOT_PASSWORD'], defaultPort: '9000', suggestedEnv: ['MINIO_CONSOLE_ADDRESS=:9001'] },
|
|
70
|
+
wordpress: { requiredEnv: ['WORDPRESS_DB_HOST', 'WORDPRESS_DB_USER', 'WORDPRESS_DB_PASSWORD', 'WORDPRESS_DB_NAME'], defaultPort: '8080', suggestedEnv: ['WORDPRESS_TABLE_PREFIX=wp_'] },
|
|
71
|
+
};
|
|
@@ -13,15 +13,26 @@
|
|
|
13
13
|
* (e.g. "NODE_ENV=production,PORT=3000")
|
|
14
14
|
* @returns {object} Docker container options ready to pass to createContainer
|
|
15
15
|
*/
|
|
16
|
-
export function buildContainerOptions({
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export function buildContainerOptions({
|
|
17
|
+
imageName: _imageName,
|
|
18
|
+
containerName,
|
|
19
|
+
portInput,
|
|
20
|
+
envInput,
|
|
21
|
+
}) {
|
|
22
|
+
const env = (envInput || '')
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((s) => s.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
const ports = (portInput || '')
|
|
27
|
+
.split(',')
|
|
28
|
+
.map((s) => s.trim())
|
|
29
|
+
.filter(Boolean);
|
|
19
30
|
|
|
20
31
|
const ExposedPorts = {};
|
|
21
32
|
const PortBindings = {};
|
|
22
33
|
|
|
23
|
-
ports.forEach(pair => {
|
|
24
|
-
const [host, cont] = pair.split(
|
|
34
|
+
ports.forEach((pair) => {
|
|
35
|
+
const [host, cont] = pair.split(':');
|
|
25
36
|
if (!host || !cont) return;
|
|
26
37
|
const key = `${cont}/tcp`;
|
|
27
38
|
ExposedPorts[key] = {};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { docker } from
|
|
2
|
-
import { imageExists, pullImage } from
|
|
3
|
-
import { TIMEOUTS } from
|
|
4
|
-
import { logger } from
|
|
1
|
+
import { docker } from '../dockerService.js';
|
|
2
|
+
import { imageExists, pullImage } from './imageUtils.js';
|
|
3
|
+
import { TIMEOUTS, IMAGE_PROFILES } from '../../constants.js';
|
|
4
|
+
import { logger } from '../../logger.js';
|
|
5
|
+
import { normalizeImageName } from '../../imageNameUtils.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Helper to add timeout to promises. If the provided promise does not settle
|
|
@@ -15,9 +16,9 @@ import { logger } from "../../logger.js";
|
|
|
15
16
|
function withTimeout(promise, ms = TIMEOUTS.CONTAINER_OP) {
|
|
16
17
|
return Promise.race([
|
|
17
18
|
promise,
|
|
18
|
-
new Promise((_, reject) =>
|
|
19
|
+
new Promise((_, reject) =>
|
|
19
20
|
setTimeout(() => reject(new Error('Operation timed out')), ms)
|
|
20
|
-
)
|
|
21
|
+
),
|
|
21
22
|
]);
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -29,13 +30,13 @@ function withTimeout(promise, ms = TIMEOUTS.CONTAINER_OP) {
|
|
|
29
30
|
* @throws {Error} If Docker reports an error
|
|
30
31
|
*/
|
|
31
32
|
export async function removeContainer(containerId) {
|
|
32
|
-
logger.debug(
|
|
33
|
+
logger.debug('Removing container %s', containerId);
|
|
33
34
|
const container = docker.getContainer(containerId);
|
|
34
35
|
try {
|
|
35
36
|
await withTimeout(container.remove({ force: true }), TIMEOUTS.CONTAINER_OP);
|
|
36
|
-
logger.info(
|
|
37
|
+
logger.info('Removed container %s', containerId);
|
|
37
38
|
} catch (err) {
|
|
38
|
-
logger.error(
|
|
39
|
+
logger.error('Failed to remove container %s', containerId, err);
|
|
39
40
|
throw new Error('Error removing container: ' + err.message);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
@@ -45,25 +46,30 @@ export async function removeContainer(containerId) {
|
|
|
45
46
|
*
|
|
46
47
|
* @param {string} imageName - Image name (e.g. 'nginx:alpine')
|
|
47
48
|
* @param {Object} [options] - Docker create options (Env, ExposedPorts, HostConfig, name, etc.)
|
|
48
|
-
* @
|
|
49
|
+
* @param {Object} [imageProfiles] - Image profiles map for defaultPort fallback
|
|
50
|
+
* @returns {Promise<{id: string, ports: Array<{containerPort: string, hostPort: string, protocol: string, source: string}>}>}
|
|
49
51
|
* @throws {Error} If image listing/pull or creation fails
|
|
50
52
|
*/
|
|
51
|
-
export async function createContainer(
|
|
52
|
-
|
|
53
|
+
export async function createContainer(
|
|
54
|
+
imageName,
|
|
55
|
+
options = {},
|
|
56
|
+
imageProfiles = IMAGE_PROFILES
|
|
57
|
+
) {
|
|
58
|
+
logger.info('Creating container from image %s', imageName);
|
|
53
59
|
let exists;
|
|
54
60
|
try {
|
|
55
61
|
exists = await withTimeout(imageExists(imageName), 10000);
|
|
56
62
|
} catch (err) {
|
|
57
|
-
logger.error(
|
|
63
|
+
logger.error('Could not check local images for %s', imageName, err);
|
|
58
64
|
throw new Error('Error listing local images: ' + err.message);
|
|
59
65
|
}
|
|
60
66
|
if (!exists) {
|
|
61
|
-
logger.info(
|
|
67
|
+
logger.info('Image %s not found locally, pulling', imageName);
|
|
62
68
|
try {
|
|
63
69
|
await withTimeout(pullImage(imageName), TIMEOUTS.PULL_IMAGE);
|
|
64
|
-
logger.info(
|
|
70
|
+
logger.info('Pulled image %s', imageName);
|
|
65
71
|
} catch (err) {
|
|
66
|
-
logger.error(
|
|
72
|
+
logger.error('Failed to pull image %s', imageName, err);
|
|
67
73
|
throw new Error('Could not pull image: ' + err.message);
|
|
68
74
|
}
|
|
69
75
|
}
|
|
@@ -73,16 +79,24 @@ export async function createContainer(imageName, options = {}) {
|
|
|
73
79
|
...options,
|
|
74
80
|
};
|
|
75
81
|
|
|
76
|
-
const hasUserDefinedPorts = Boolean(
|
|
82
|
+
const hasUserDefinedPorts = Boolean(
|
|
83
|
+
createOpts.ExposedPorts && Object.keys(createOpts.ExposedPorts).length
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/** @type {Array<{containerPort: string, hostPort: string, protocol: string, source: string}>} */
|
|
87
|
+
const assignedPorts = [];
|
|
77
88
|
|
|
78
89
|
if (!hasUserDefinedPorts) {
|
|
79
90
|
try {
|
|
80
91
|
const image = docker.getImage(imageName);
|
|
81
|
-
if (image && typeof image.inspect ===
|
|
92
|
+
if (image && typeof image.inspect === 'function') {
|
|
82
93
|
let usedHostPorts = new Set();
|
|
83
|
-
if (typeof docker.listContainers ===
|
|
94
|
+
if (typeof docker.listContainers === 'function') {
|
|
84
95
|
try {
|
|
85
|
-
const containers = await withTimeout(
|
|
96
|
+
const containers = await withTimeout(
|
|
97
|
+
docker.listContainers({ all: true }),
|
|
98
|
+
TIMEOUTS.CONTAINER_OP
|
|
99
|
+
);
|
|
86
100
|
containers.forEach((container) => {
|
|
87
101
|
(container.Ports || []).forEach((portInfo) => {
|
|
88
102
|
if (portInfo && portInfo.PublicPort) {
|
|
@@ -91,7 +105,10 @@ export async function createContainer(imageName, options = {}) {
|
|
|
91
105
|
});
|
|
92
106
|
});
|
|
93
107
|
} catch (err) {
|
|
94
|
-
logger.warn(
|
|
108
|
+
logger.warn(
|
|
109
|
+
'Could not inspect existing containers to auto-map ports',
|
|
110
|
+
err
|
|
111
|
+
);
|
|
95
112
|
}
|
|
96
113
|
}
|
|
97
114
|
const reservePort = (port) => {
|
|
@@ -101,7 +118,6 @@ export async function createContainer(imageName, options = {}) {
|
|
|
101
118
|
const pickNextAvailablePort = (base) => {
|
|
102
119
|
const numericBase = Number.parseInt(base, 10);
|
|
103
120
|
if (Number.isNaN(numericBase)) {
|
|
104
|
-
// If the base port is non-numeric, just return the base.
|
|
105
121
|
if (!usedHostPorts.has(base)) {
|
|
106
122
|
usedHostPorts.add(base);
|
|
107
123
|
return base;
|
|
@@ -123,36 +139,93 @@ export async function createContainer(imageName, options = {}) {
|
|
|
123
139
|
return String(candidate);
|
|
124
140
|
};
|
|
125
141
|
|
|
126
|
-
const inspectData = await withTimeout(
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
const inspectData = await withTimeout(
|
|
143
|
+
image.inspect(),
|
|
144
|
+
TIMEOUTS.CONTAINER_OP
|
|
145
|
+
);
|
|
146
|
+
let exposed =
|
|
147
|
+
inspectData?.Config?.ExposedPorts ||
|
|
148
|
+
inspectData?.ContainerConfig?.ExposedPorts ||
|
|
149
|
+
{};
|
|
150
|
+
let exposedKeys = Object.keys(exposed || {});
|
|
151
|
+
|
|
152
|
+
// If no ExposedPorts in image, fall back to IMAGE_PROFILES defaultPort
|
|
153
|
+
if (!exposedKeys.length) {
|
|
154
|
+
const baseName = normalizeImageName(imageName);
|
|
155
|
+
const profile = imageProfiles[baseName];
|
|
156
|
+
if (profile && profile.defaultPort) {
|
|
157
|
+
const fallbackKey = `${profile.defaultPort}/tcp`;
|
|
158
|
+
exposed = { [fallbackKey]: {} };
|
|
159
|
+
exposedKeys = [fallbackKey];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
129
163
|
if (exposedKeys.length) {
|
|
130
164
|
createOpts.ExposedPorts = createOpts.ExposedPorts || {};
|
|
131
165
|
createOpts.HostConfig = { ...(createOpts.HostConfig || {}) };
|
|
132
|
-
createOpts.HostConfig.PortBindings = {
|
|
166
|
+
createOpts.HostConfig.PortBindings = {
|
|
167
|
+
...(createOpts.HostConfig.PortBindings || {}),
|
|
168
|
+
};
|
|
133
169
|
exposedKeys.forEach((portKey) => {
|
|
134
170
|
if (!createOpts.ExposedPorts[portKey]) {
|
|
135
171
|
createOpts.ExposedPorts[portKey] = exposed[portKey] || {};
|
|
136
172
|
}
|
|
137
|
-
if (
|
|
138
|
-
|
|
173
|
+
if (
|
|
174
|
+
!createOpts.HostConfig.PortBindings[portKey] ||
|
|
175
|
+
!createOpts.HostConfig.PortBindings[portKey].length
|
|
176
|
+
) {
|
|
177
|
+
const parts = portKey.split('/');
|
|
178
|
+
const containerPort = parts[0];
|
|
179
|
+
const protocol = parts[1] || 'tcp';
|
|
139
180
|
const hostPort = pickNextAvailablePort(containerPort);
|
|
140
|
-
createOpts.HostConfig.PortBindings[portKey] = [
|
|
181
|
+
createOpts.HostConfig.PortBindings[portKey] = [
|
|
182
|
+
{ HostPort: hostPort },
|
|
183
|
+
];
|
|
184
|
+
assignedPorts.push({
|
|
185
|
+
containerPort,
|
|
186
|
+
hostPort,
|
|
187
|
+
protocol,
|
|
188
|
+
source: 'auto',
|
|
189
|
+
});
|
|
141
190
|
}
|
|
142
191
|
});
|
|
143
192
|
}
|
|
144
193
|
}
|
|
145
194
|
} catch (err) {
|
|
146
|
-
logger.warn(
|
|
195
|
+
logger.warn(
|
|
196
|
+
'Could not inspect image %s for default ports',
|
|
197
|
+
imageName,
|
|
198
|
+
err
|
|
199
|
+
);
|
|
147
200
|
}
|
|
201
|
+
} else {
|
|
202
|
+
// User-defined ports: collect them with source: "user"
|
|
203
|
+
const portBindings = createOpts.HostConfig?.PortBindings || {};
|
|
204
|
+
Object.entries(portBindings).forEach(([portKey, bindings]) => {
|
|
205
|
+
const parts = portKey.split('/');
|
|
206
|
+
const containerPort = parts[0];
|
|
207
|
+
const protocol = parts[1] || 'tcp';
|
|
208
|
+
(bindings || []).forEach((b) => {
|
|
209
|
+
assignedPorts.push({
|
|
210
|
+
containerPort,
|
|
211
|
+
hostPort: b.HostPort,
|
|
212
|
+
protocol,
|
|
213
|
+
source: 'user',
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
148
217
|
}
|
|
218
|
+
|
|
149
219
|
try {
|
|
150
|
-
const container = await withTimeout(
|
|
220
|
+
const container = await withTimeout(
|
|
221
|
+
docker.createContainer(createOpts),
|
|
222
|
+
TIMEOUTS.CONTAINER_OP
|
|
223
|
+
);
|
|
151
224
|
const containerId = container.id || container.Id;
|
|
152
|
-
logger.info(
|
|
153
|
-
return
|
|
225
|
+
logger.info('Created container %s from image %s', containerId, imageName);
|
|
226
|
+
return { id: containerId, ports: assignedPorts };
|
|
154
227
|
} catch (err) {
|
|
155
|
-
logger.error(
|
|
228
|
+
logger.error('Failed to create container from image %s', imageName, err);
|
|
156
229
|
throw new Error('Error creating container: ' + err.message);
|
|
157
230
|
}
|
|
158
231
|
}
|
|
@@ -164,12 +237,12 @@ export async function createContainer(imageName, options = {}) {
|
|
|
164
237
|
*/
|
|
165
238
|
export async function startContainer(containerId) {
|
|
166
239
|
const container = docker.getContainer(containerId);
|
|
167
|
-
logger.debug(
|
|
240
|
+
logger.debug('Starting container %s', containerId);
|
|
168
241
|
try {
|
|
169
242
|
await withTimeout(container.start(), TIMEOUTS.CONTAINER_OP);
|
|
170
|
-
logger.info(
|
|
243
|
+
logger.info('Started container %s', containerId);
|
|
171
244
|
} catch (err) {
|
|
172
|
-
logger.error(
|
|
245
|
+
logger.error('Failed to start container %s', containerId, err);
|
|
173
246
|
throw err;
|
|
174
247
|
}
|
|
175
248
|
}
|
|
@@ -181,12 +254,12 @@ export async function startContainer(containerId) {
|
|
|
181
254
|
*/
|
|
182
255
|
export async function stopContainer(containerId) {
|
|
183
256
|
const container = docker.getContainer(containerId);
|
|
184
|
-
logger.debug(
|
|
257
|
+
logger.debug('Stopping container %s', containerId);
|
|
185
258
|
try {
|
|
186
259
|
await withTimeout(container.stop(), TIMEOUTS.CONTAINER_OP);
|
|
187
|
-
logger.info(
|
|
260
|
+
logger.info('Stopped container %s', containerId);
|
|
188
261
|
} catch (err) {
|
|
189
|
-
logger.error(
|
|
262
|
+
logger.error('Failed to stop container %s', containerId, err);
|
|
190
263
|
throw err;
|
|
191
264
|
}
|
|
192
265
|
}
|
|
@@ -198,12 +271,12 @@ export async function stopContainer(containerId) {
|
|
|
198
271
|
*/
|
|
199
272
|
export async function restartContainer(containerId) {
|
|
200
273
|
const container = docker.getContainer(containerId);
|
|
201
|
-
logger.debug(
|
|
274
|
+
logger.debug('Restarting container %s', containerId);
|
|
202
275
|
try {
|
|
203
276
|
await withTimeout(container.restart(), TIMEOUTS.CONTAINER_OP);
|
|
204
|
-
logger.info(
|
|
277
|
+
logger.info('Restarted container %s', containerId);
|
|
205
278
|
} catch (err) {
|
|
206
|
-
logger.error(
|
|
279
|
+
logger.error('Failed to restart container %s', containerId, err);
|
|
207
280
|
throw err;
|
|
208
281
|
}
|
|
209
282
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { docker } from
|
|
2
|
-
import { logger } from
|
|
1
|
+
import { docker } from '../dockerService.js';
|
|
2
|
+
import { logger } from '../../logger.js';
|
|
3
3
|
|
|
4
|
-
const DEFAULT_CONTAINER_NAME =
|
|
4
|
+
const DEFAULT_CONTAINER_NAME = 'Unknown';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Produce a safe, display-friendly container name from the raw Docker payload.
|
|
@@ -13,21 +13,28 @@ function normalizeContainerName(container) {
|
|
|
13
13
|
const names = container?.Names;
|
|
14
14
|
|
|
15
15
|
let rawName;
|
|
16
|
-
if (
|
|
16
|
+
if (
|
|
17
|
+
Array.isArray(names) &&
|
|
18
|
+
names.length > 0 &&
|
|
19
|
+
typeof names[0] === 'string'
|
|
20
|
+
) {
|
|
17
21
|
rawName = names[0];
|
|
18
|
-
} else if (typeof names ===
|
|
22
|
+
} else if (typeof names === 'string' && names.trim().length > 0) {
|
|
19
23
|
rawName = names;
|
|
20
|
-
} else if (
|
|
24
|
+
} else if (
|
|
25
|
+
typeof container?.Name === 'string' &&
|
|
26
|
+
container.Name.trim().length > 0
|
|
27
|
+
) {
|
|
21
28
|
rawName = container.Name;
|
|
22
29
|
} else {
|
|
23
30
|
rawName = DEFAULT_CONTAINER_NAME;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
if (typeof rawName !==
|
|
33
|
+
if (typeof rawName !== 'string') {
|
|
27
34
|
return DEFAULT_CONTAINER_NAME;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
const cleanedName = rawName.replace(/^\/+/u,
|
|
37
|
+
const cleanedName = rawName.replace(/^\/+/u, '').trim();
|
|
31
38
|
return cleanedName.length > 0 ? cleanedName : DEFAULT_CONTAINER_NAME;
|
|
32
39
|
}
|
|
33
40
|
|
|
@@ -36,33 +43,32 @@ function normalizeContainerName(container) {
|
|
|
36
43
|
* @returns {Promise<Array<Object>>}
|
|
37
44
|
*/
|
|
38
45
|
export async function getContainers() {
|
|
39
|
-
logger.debug(
|
|
46
|
+
logger.debug('Listing containers');
|
|
40
47
|
try {
|
|
41
48
|
const containers = await docker.listContainers({ all: true });
|
|
42
|
-
logger.debug(
|
|
49
|
+
logger.debug('Retrieved %d containers', containers.length);
|
|
43
50
|
return containers.map((container) => ({
|
|
44
51
|
id: container.Id,
|
|
45
52
|
name: normalizeContainerName(container),
|
|
46
53
|
image: container.Image,
|
|
47
54
|
state: container.State,
|
|
48
55
|
status: container.Status,
|
|
49
|
-
ports:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
})()
|
|
56
|
+
ports: (() => {
|
|
57
|
+
const publicPorts = container.Ports.filter(
|
|
58
|
+
(port) => port.PublicPort
|
|
59
|
+
).map((port) => `${port.PublicPort}:${port.PrivatePort}`);
|
|
60
|
+
if (publicPorts.length > 0) {
|
|
61
|
+
return [...new Set(publicPorts)];
|
|
62
|
+
}
|
|
63
|
+
// Fall back to exposed private ports when there are no bindings.
|
|
64
|
+
const privatePorts = container.Ports.filter(
|
|
65
|
+
(port) => port.PrivatePort
|
|
66
|
+
).map((port) => `${port.PrivatePort}`);
|
|
67
|
+
return [...new Set(privatePorts)];
|
|
68
|
+
})(),
|
|
63
69
|
}));
|
|
64
70
|
} catch (err) {
|
|
65
|
-
logger.error(
|
|
71
|
+
logger.error('Failed to list containers', err);
|
|
66
72
|
throw err;
|
|
67
73
|
}
|
|
68
74
|
}
|