Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
/* eslint-disable no-undef */
|
|
|
|
function shQuote(str) {
|
|
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
|
|
}
|
|
|
|
function sanitizeDockerId(id) {
|
|
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
|
}
|
|
|
|
function sanitizeContainerName(name) {
|
|
const trimmed = String(name || "").trim().slice(0, 128);
|
|
if (!trimmed) return null;
|
|
return trimmed.replace(/[^a-zA-Z0-9_.-]/g, "") || null;
|
|
}
|
|
|
|
function sanitizeImageRef(ref) {
|
|
const trimmed = String(ref || "").trim().slice(0, 256);
|
|
return trimmed || null;
|
|
}
|
|
|
|
function isSuccessfulCommandResult(result) {
|
|
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
|
|
}
|
|
|
|
function dockerCommandError(result, fallback) {
|
|
return (result?.stderr || result?.error || "").trim() || fallback;
|
|
}
|
|
|
|
function isDockerSocketPermissionError(result) {
|
|
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
|
if (!text.includes("permission denied")) return false;
|
|
return text.includes("docker daemon")
|
|
|| text.includes("docker.sock")
|
|
|| text.includes("/var/run/docker.sock")
|
|
|| text.includes("connect to the docker daemon");
|
|
}
|
|
|
|
function getSessionSudoPassword(session) {
|
|
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
|
|
? session.systemManagerSudoPassword
|
|
: null;
|
|
}
|
|
|
|
function buildDockerCommand(args) {
|
|
return `docker ${args}`.trim();
|
|
}
|
|
|
|
function buildPasswordlessSudoDockerCommand(args) {
|
|
return `sudo ${buildDockerCommand(args)}`;
|
|
}
|
|
|
|
function buildSudoDockerCommand(args) {
|
|
return `sudo -S -p '' ${buildDockerCommand(args)}`;
|
|
}
|
|
|
|
function parseDockerContainers(stdout) {
|
|
const containers = [];
|
|
for (const line of (stdout || "").split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const row = JSON.parse(trimmed);
|
|
containers.push({
|
|
id: row.ID || row.Id || "",
|
|
name: (row.Names || row.Name || "").replace(/^\//, ""),
|
|
image: row.Image || "",
|
|
status: row.Status || row.State || "",
|
|
state: row.State || "",
|
|
ports: row.Ports || "",
|
|
createdAt: row.CreatedAt || row.Created || "",
|
|
});
|
|
} catch {
|
|
// skip malformed line
|
|
}
|
|
}
|
|
return containers;
|
|
}
|
|
|
|
function parseDockerStats(stdout) {
|
|
const stats = [];
|
|
for (const line of (stdout || "").split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const row = JSON.parse(trimmed);
|
|
stats.push({
|
|
id: row.ID || row.Container || "",
|
|
name: row.Name || "",
|
|
cpuPercent: parseFloat(String(row.CPUPerc || "0").replace("%", "")) || 0,
|
|
memUsage: row.MemUsage || "",
|
|
memPercent: parseFloat(String(row.MemPerc || "0").replace("%", "")) || 0,
|
|
netIO: row.NetIO || "",
|
|
blockIO: row.BlockIO || "",
|
|
pids: Number(row.PIDs || row.Pids || 0) || 0,
|
|
});
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
function parseDockerImages(stdout) {
|
|
const images = [];
|
|
for (const line of (stdout || "").split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const row = JSON.parse(trimmed);
|
|
const repository = row.Repository || "";
|
|
const tag = row.Tag || "";
|
|
images.push({
|
|
id: row.ID || row.Id || "",
|
|
repository,
|
|
tag,
|
|
size: row.Size || "",
|
|
createdAt: row.CreatedAt || row.CreatedSince || "",
|
|
digest: row.Digest || "",
|
|
name: repository && tag ? `${repository}:${tag}` : repository || tag || row.ID || "",
|
|
});
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
return images;
|
|
}
|
|
|
|
function summarizeImageInspect(info) {
|
|
if (!info) return null;
|
|
return {
|
|
id: info.Id,
|
|
repoTags: info.RepoTags,
|
|
repoDigests: info.RepoDigests,
|
|
created: info.Created,
|
|
size: info.Size,
|
|
architecture: info.Architecture,
|
|
os: info.Os,
|
|
config: {
|
|
env: info.Config?.Env,
|
|
cmd: info.Config?.Cmd,
|
|
entrypoint: info.Config?.Entrypoint,
|
|
workingDir: info.Config?.WorkingDir,
|
|
exposedPorts: info.Config?.ExposedPorts,
|
|
labels: info.Config?.Labels,
|
|
},
|
|
rootfs: info.RootFS,
|
|
history: Array.isArray(info.History) ? info.History.slice(0, 5) : undefined,
|
|
};
|
|
}
|
|
|
|
function summarizeContainerInspect(info) {
|
|
if (!info) return null;
|
|
return {
|
|
id: info.Id,
|
|
name: info.Name,
|
|
image: info.Config?.Image,
|
|
state: info.State,
|
|
network: info.NetworkSettings,
|
|
mounts: info.Mounts,
|
|
env: info.Config?.Env,
|
|
labels: info.Config?.Labels,
|
|
created: info.Created,
|
|
path: info.Path,
|
|
args: info.Args,
|
|
restartPolicy: info.HostConfig?.RestartPolicy,
|
|
};
|
|
}
|
|
|
|
function createDockerOpsApi({ execOnSession, getSession }) {
|
|
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
|
|
const cmd = buildDockerCommand(args);
|
|
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
|
|
if (isSuccessfulCommandResult(result)) return result;
|
|
|
|
if (isDockerSocketPermissionError(result)) {
|
|
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
|
|
let lastSudoResult = null;
|
|
|
|
const nopasswdResult = await execOnSession(
|
|
event,
|
|
sessionId,
|
|
buildPasswordlessSudoDockerCommand(args),
|
|
timeoutMs,
|
|
);
|
|
if (isSuccessfulCommandResult(nopasswdResult)) return nopasswdResult;
|
|
lastSudoResult = nopasswdResult;
|
|
|
|
if (sudoPassword) {
|
|
const sudoResult = await execOnSession(
|
|
event,
|
|
sessionId,
|
|
buildSudoDockerCommand(args),
|
|
timeoutMs,
|
|
{ stdin: `${sudoPassword}\n` },
|
|
);
|
|
if (isSuccessfulCommandResult(sudoResult)) return sudoResult;
|
|
lastSudoResult = sudoResult;
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: dockerCommandError(lastSudoResult, `sudo docker exited with code ${lastSudoResult?.code}`),
|
|
stderr: lastSudoResult?.stderr,
|
|
};
|
|
}
|
|
|
|
if (!result.success) return result;
|
|
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
|
|
return {
|
|
success: false,
|
|
error: dockerCommandError(result, `docker exited with code ${result.code}`),
|
|
stderr: result.stderr,
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function listContainers(event, sessionId) {
|
|
const result = await runDocker(event, sessionId, 'ps -a --format "{{json .}}"', 12000);
|
|
if (!result.success) return { success: false, error: result.error };
|
|
return { success: true, containers: parseDockerContainers(result.stdout) };
|
|
}
|
|
|
|
async function listImages(event, sessionId) {
|
|
const result = await runDocker(event, sessionId, 'images --format "{{json .}}"', 12000);
|
|
if (!result.success) return { success: false, error: result.error };
|
|
return { success: true, images: parseDockerImages(result.stdout) };
|
|
}
|
|
|
|
async function getStats(event, payload) {
|
|
const sessionId = payload?.sessionId;
|
|
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
|
const ids = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : [];
|
|
const idArg = ids.map((id) => sanitizeDockerId(id)).filter(Boolean).join(" ");
|
|
const result = await runDocker(
|
|
event,
|
|
sessionId,
|
|
`stats --no-stream --format "{{json .}}" ${idArg}`.trim(),
|
|
15000,
|
|
);
|
|
if (!result.success) return { success: false, error: result.error };
|
|
return { success: true, stats: parseDockerStats(result.stdout) };
|
|
}
|
|
|
|
async function inspectContainer(event, payload) {
|
|
const { sessionId, containerId } = payload || {};
|
|
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
|
|
const safeId = sanitizeDockerId(containerId);
|
|
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000);
|
|
if (!result.success) return { success: false, error: result.error };
|
|
try {
|
|
const parsed = JSON.parse(result.stdout || "[]");
|
|
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
return { success: true, inspect: summarizeContainerInspect(info) };
|
|
} catch {
|
|
return { success: false, error: "Failed to parse inspect output" };
|
|
}
|
|
}
|
|
|
|
async function inspectImage(event, payload) {
|
|
const { sessionId, imageId } = payload || {};
|
|
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
|
|
const safeId = sanitizeDockerId(imageId);
|
|
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000);
|
|
if (!result.success) return { success: false, error: result.error };
|
|
try {
|
|
const parsed = JSON.parse(result.stdout || "[]");
|
|
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
return { success: true, inspect: summarizeImageInspect(info) };
|
|
} catch {
|
|
return { success: false, error: "Failed to parse image inspect output" };
|
|
}
|
|
}
|
|
|
|
async function containerAction(event, payload) {
|
|
const { sessionId, containerId, action, newName } = payload || {};
|
|
if (!sessionId || !containerId || !action) return { success: false, error: "Missing params" };
|
|
const safeId = sanitizeDockerId(containerId);
|
|
|
|
switch (action) {
|
|
case "start":
|
|
return runDocker(event, sessionId, `start ${safeId}`);
|
|
case "stop":
|
|
return runDocker(event, sessionId, `stop ${safeId}`);
|
|
case "restart":
|
|
return runDocker(event, sessionId, `restart ${safeId}`);
|
|
case "rm":
|
|
return runDocker(event, sessionId, `rm -f ${safeId}`);
|
|
case "pause":
|
|
return runDocker(event, sessionId, `pause ${safeId}`);
|
|
case "unpause":
|
|
return runDocker(event, sessionId, `unpause ${safeId}`);
|
|
case "kill":
|
|
return runDocker(event, sessionId, `kill ${safeId}`);
|
|
case "rename": {
|
|
const next = sanitizeContainerName(newName);
|
|
if (!next) return { success: false, error: "Invalid container name" };
|
|
return runDocker(event, sessionId, `rename ${safeId} ${shQuote(next)}`);
|
|
}
|
|
default:
|
|
return { success: false, error: `Invalid container action: ${action}` };
|
|
}
|
|
}
|
|
|
|
async function imageAction(event, payload) {
|
|
const { sessionId, action, imageRef, imageId, force, all, repository, tag } = payload || {};
|
|
if (!sessionId || !action) return { success: false, error: "Missing params" };
|
|
|
|
switch (action) {
|
|
case "pull": {
|
|
const ref = sanitizeImageRef(imageRef);
|
|
if (!ref) return { success: false, error: "Missing image reference" };
|
|
return runDocker(event, sessionId, `pull ${shQuote(ref)}`, 600000);
|
|
}
|
|
case "rm": {
|
|
const safeId = sanitizeDockerId(imageId);
|
|
if (!safeId) return { success: false, error: "Missing image id" };
|
|
const forceFlag = force ? " -f" : "";
|
|
return runDocker(event, sessionId, `rmi${forceFlag} ${safeId}`);
|
|
}
|
|
case "prune": {
|
|
const allFlag = all ? " -a" : "";
|
|
return runDocker(event, sessionId, `image prune${allFlag} -f`, 120000);
|
|
}
|
|
case "tag": {
|
|
const safeId = sanitizeDockerId(imageId);
|
|
const repo = sanitizeImageRef(repository);
|
|
const tagName = String(tag || "").trim().slice(0, 128) || "latest";
|
|
if (!safeId || !repo) return { success: false, error: "Missing params" };
|
|
return runDocker(
|
|
event,
|
|
sessionId,
|
|
`tag ${safeId} ${shQuote(`${repo}:${tagName}`)}`,
|
|
);
|
|
}
|
|
default:
|
|
return { success: false, error: `Invalid image action: ${action}` };
|
|
}
|
|
}
|
|
|
|
return {
|
|
listContainers,
|
|
listImages,
|
|
getStats,
|
|
inspectContainer,
|
|
inspectImage,
|
|
containerAction,
|
|
imageAction,
|
|
parseDockerContainers,
|
|
parseDockerStats,
|
|
parseDockerImages,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
createDockerOpsApi,
|
|
parseDockerContainers,
|
|
parseDockerStats,
|
|
parseDockerImages,
|
|
};
|