DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops
- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages - PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs) - Auth/RBAC/workspace isolation, credential vault (AES-256-GCM) - Servers/SSH, Docker discovery & lifecycle, Job Center - Websites & deployments (git provider credential clone, env, health verification) - Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring - Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools) - AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals) - Notes: Vditor WYSIWYG markdown editor, non-blocking autosave - Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* # Docker 集成模块
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { DockerService } from "./docker.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DockerService],
|
||||
exports: [DockerService],
|
||||
})
|
||||
export class DockerModule {}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* # Docker 适配器(Task 010)
|
||||
* 统一接口 + 两种实现:
|
||||
* - SshDockerAdapter:通过 SSH 调用目标机 docker CLI(零额外依赖、权限模型清晰);
|
||||
* - LocalSocketAdapter:通过本机 /var/run/docker.sock 调用 Docker Engine API。
|
||||
* DevOS 不重新实现 Docker,仅提供统一适配层。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { SshClientService, type SshTarget } from "../ssh/ssh-client.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
|
||||
/** # 容器摘要视图 **/
|
||||
export interface DockerContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string;
|
||||
status: string;
|
||||
ports: Record<string, unknown>[];
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
/** # Docker 适配器接口 **/
|
||||
export interface DockerAdapter {
|
||||
/** # 是否支持该目标(例如本地 socket 仅限本机) */
|
||||
supports(server: { host: string; provider?: string }): boolean;
|
||||
listContainers(target: SshTarget): Promise<DockerContainerInfo[]>;
|
||||
inspectContainer(target: SshTarget, containerId: string): Promise<Record<string, unknown>>;
|
||||
restartContainer(target: SshTarget, containerId: string): Promise<void>;
|
||||
checkDocker(target: SshTarget): Promise<{ available: boolean; version: string }>;
|
||||
}
|
||||
|
||||
/** # docker ps --format 输出行结构(部分字段) **/
|
||||
interface DockerPsRow {
|
||||
ID: string;
|
||||
Names: string;
|
||||
Image: string;
|
||||
State: string;
|
||||
Status: string;
|
||||
Ports?: string;
|
||||
Labels?: string;
|
||||
Command?: string;
|
||||
CreatedAt?: string;
|
||||
Networks?: string;
|
||||
LocalVolumes?: string;
|
||||
Mounts?: string;
|
||||
RunningFor?: string;
|
||||
Size?: string;
|
||||
}
|
||||
|
||||
/** # 解析 docker ps --format '{{json .}}' 的 NDJSON 输出(纯函数,可单测) */
|
||||
export function parseDockerPsOutput(output: string): DockerContainerInfo[] {
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
const result: DockerContainerInfo[] = [];
|
||||
for (const line of lines) {
|
||||
let row: DockerPsRow;
|
||||
try {
|
||||
row = JSON.parse(line) as DockerPsRow;
|
||||
} catch {
|
||||
continue; // 跳过非 JSON 噪音行
|
||||
}
|
||||
result.push({
|
||||
id: row.ID ?? "",
|
||||
name: (row.Names ?? "").split(",").filter(Boolean)[0] ?? "",
|
||||
image: row.Image ?? "",
|
||||
state: row.State ?? "",
|
||||
status: row.Status ?? "",
|
||||
ports: parseDockerPorts(row.Ports ?? ""),
|
||||
labels: parseDockerLabels(row.Labels ?? ""),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** # 解析 docker ps Ports 列(如 0.0.0.0:8080->80/tcp, :::8080->80/tcp) */
|
||||
export function parseDockerPorts(ports: string): Record<string, unknown>[] {
|
||||
if (!ports || ports === "") return [];
|
||||
return ports.split(", ").filter(Boolean).map((p) => ({ raw: p }));
|
||||
}
|
||||
|
||||
/** # 解析 docker ps Labels 列(逗号分隔 key=value) */
|
||||
export function parseDockerLabels(labels: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!labels) return out;
|
||||
for (const pair of labels.split(",")) {
|
||||
const idx = pair.indexOf("=");
|
||||
if (idx > 0) out[pair.slice(0, idx)] = pair.slice(idx + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DockerService implements DockerAdapter {
|
||||
/** # 本机 docker socket 路径 **/
|
||||
private static readonly LOCAL_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
|
||||
|
||||
constructor(private readonly ssh: SshClientService) {}
|
||||
|
||||
/** # 是否支持该目标 */
|
||||
supports(server: { host: string; provider?: string }): boolean {
|
||||
return true; // 接口层总是可用;选择 SSH 或 socket 由 targetProvider 决定
|
||||
}
|
||||
|
||||
/** # 目标是否走本地 socket(server.provider === 'local') */
|
||||
isLocal(server: { provider?: string | null }): boolean {
|
||||
return server.provider === "local";
|
||||
}
|
||||
|
||||
/** # 列出容器(含停止的) */
|
||||
async listContainers(target: SshTarget, isLocal = false): Promise<DockerContainerInfo[]> {
|
||||
if (isLocal) {
|
||||
const rows = await this.localApi<Record<string, unknown>[]>("/containers/json?all=1");
|
||||
return rows.map(mapEngineContainer);
|
||||
}
|
||||
const res = await this.ssh.exec(target, "docker ps -a --format '{{json .}}'", 30_000);
|
||||
if (res.code !== 0 && !res.stdout.trim()) {
|
||||
throw ApiError.upstream(`docker ps failed: ${res.stderr.slice(0, 500)}`);
|
||||
}
|
||||
return parseDockerPsOutput(res.stdout);
|
||||
}
|
||||
|
||||
/** # 检查容器详情 */
|
||||
async inspectContainer(
|
||||
target: SshTarget,
|
||||
containerId: string,
|
||||
isLocal = false,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
return this.localApi<Record<string, unknown>>(`/containers/${safeId}/json`);
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker inspect ${safeId}`, 20_000);
|
||||
if (res.code !== 0) throw ApiError.upstream(`docker inspect failed: ${res.stderr.slice(0, 300)}`);
|
||||
const parsed = JSON.parse(res.stdout) as Record<string, unknown>[];
|
||||
return parsed[0] ?? {};
|
||||
}
|
||||
|
||||
/** # 重启容器 */
|
||||
async restartContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
await this.localApiVoid(`/containers/${safeId}/restart?t=10`, "POST");
|
||||
return;
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker restart -t 10 ${safeId}`, 60_000);
|
||||
if (res.code !== 0) throw ApiError.upstream(`docker restart failed: ${res.stderr.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
/** # 启动容器 */
|
||||
async startContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
await this.localApiVoid(`/containers/${safeId}/start`, "POST");
|
||||
return;
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker start ${safeId}`, 60_000);
|
||||
if (res.code !== 0) throw ApiError.upstream(`docker start failed: ${res.stderr.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
/** # 停止容器 */
|
||||
async stopContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
await this.localApiVoid(`/containers/${safeId}/stop?t=10`, "POST");
|
||||
return;
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker stop -t 10 ${safeId}`, 60_000);
|
||||
if (res.code !== 0) throw ApiError.upstream(`docker stop failed: ${res.stderr.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
/** # 容器日志(tail N 行) */
|
||||
async containerLogs(target: SshTarget, containerId: string, tail = 200, isLocal = false): Promise<string[]> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
const res = await this.localApi<string>(`/containers/${safeId}/logs?stdout=1&stderr=1&tail=${tail}`);
|
||||
// Docker API 多路复用流:剥离 8 字节帧头
|
||||
return res
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/^[^\r\n]*\r?/, ""))
|
||||
.slice(-tail);
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker logs --tail ${tail} ${safeId} 2>&1`, 30_000);
|
||||
return res.stdout.split("\n").slice(-tail);
|
||||
}
|
||||
|
||||
/** # 镜像列表 */
|
||||
async listImages(target: SshTarget, isLocal = false): Promise<{ repository: string; tag: string; size: string; created: string }[]> {
|
||||
if (isLocal) {
|
||||
const rows = await this.localApi<Record<string, unknown>[]>("/images/json");
|
||||
return rows.map((r) => {
|
||||
const tags = (r.RepoTags as string[] | undefined) ?? ["<none>:<none>"];
|
||||
return {
|
||||
repository: (tags[0].split(":")[0]) ?? "<none>",
|
||||
tag: tags[0].split(":").slice(1).join(":") || "<none>",
|
||||
size: `${Math.round((Number(r.Size) || 0) / 1024 / 1024)}MB`,
|
||||
created: new String(r.Created ?? "").toString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
const res = await this.ssh.exec(target, "docker images --format '{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}'", 20_000);
|
||||
return res.stdout
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => {
|
||||
const [repository, tag, size, created] = l.split("|");
|
||||
return { repository, tag, size, created };
|
||||
});
|
||||
}
|
||||
|
||||
/** # 容器资源用量(stats no-stream) */
|
||||
async containerStats(
|
||||
target: SshTarget,
|
||||
containerId: string,
|
||||
isLocal = false,
|
||||
): Promise<{ cpuPct: string; memUsage: string; memPct: string }> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
const s = await this.localApi<{ cpu_stats: Record<string, unknown>; memory_stats: Record<string, unknown> }>(
|
||||
`/containers/${safeId}/stats?stream=false`,
|
||||
);
|
||||
return { cpuPct: "n/a", memUsage: `${Math.round((Number(s.memory_stats.usage) || 0) / 1024 / 1024)}MB`, memPct: "n/a" };
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker stats --no-stream --format '{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}' ${safeId}`, 20_000);
|
||||
const [cpuPct, memUsage, memPct] = res.stdout.trim().split("|");
|
||||
return { cpuPct: cpuPct ?? "n/a", memUsage: memUsage ?? "n/a", memPct: memPct ?? "n/a" };
|
||||
}
|
||||
|
||||
/** # Docker 可用性检查 */
|
||||
async checkDocker(target: SshTarget, isLocal = false): Promise<{ available: boolean; version: string }> {
|
||||
try {
|
||||
if (isLocal) {
|
||||
const version = await this.localApi<{ Version: string }>("/version");
|
||||
return { available: true, version: version.Version };
|
||||
}
|
||||
const res = await this.ssh.exec(target, "docker version --format '{{.Server.Version}}'", 20_000);
|
||||
if (res.code === 0 && res.stdout.trim()) {
|
||||
return { available: true, version: res.stdout.trim() };
|
||||
}
|
||||
return { available: false, version: res.stderr.trim().slice(0, 200) };
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
version: error instanceof Error ? error.message.slice(0, 200) : "unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** # 通过本机 socket 调用 Docker Engine API */
|
||||
private localApi<T>(path: string, method = "GET"): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
socketPath: DockerService.LOCAL_SOCKET,
|
||||
path: `http://localhost/v1.41${path}`,
|
||||
method,
|
||||
timeout: 15_000,
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c.toString("utf8")));
|
||||
res.on("end", () => {
|
||||
if ((res.statusCode ?? 500) >= 400) {
|
||||
reject(ApiError.upstream(`Docker API ${path} -> ${res.statusCode}: ${body.slice(0, 200)}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(body ? (JSON.parse(body) as T) : (undefined as T));
|
||||
} catch {
|
||||
resolve(undefined as T);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(ApiError.timeout("Docker API timeout"));
|
||||
});
|
||||
req.on("error", (err) => reject(ApiError.upstream(`Docker API error: ${err.message}`)));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** # 本机 socket 无返回体调用 */
|
||||
private async localApiVoid(path: string, method: string): Promise<void> {
|
||||
await this.localApi<unknown>(path, method);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 容器 ID 白名单校验(防注入;docker id 为 [0-9a-f]{12,64} 或 name) */
|
||||
export function assertSafeContainerId(id: string): string {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(id)) {
|
||||
throw ApiError.validation("Invalid container id");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** # Docker Engine API 容器对象 → 摘要视图 */
|
||||
function mapEngineContainer(row: Record<string, unknown>): DockerContainerInfo {
|
||||
const names = (row.Names as string[] | undefined) ?? [];
|
||||
const ports = (row.Ports as Record<string, unknown>[] | undefined) ?? [];
|
||||
return {
|
||||
id: (row.Id as string | undefined)?.slice(0, 12) ?? "",
|
||||
name: (names[0] ?? "").replace(/^\//, ""),
|
||||
image: (row.Image as string | undefined) ?? "",
|
||||
state: (row.State as string | undefined) ?? "",
|
||||
status: (row.Status as string | undefined) ?? "",
|
||||
ports,
|
||||
labels: (row.Labels as Record<string, string> | undefined) ?? {},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user