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:
phptits
2026-09-10 06:35:55 +00:00
commit 0d6cd37579
209 changed files with 22783 additions and 0 deletions
@@ -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) ?? {},
};
}