feat: add nonblocking hwlab cicd cli
This commit is contained in:
@@ -58,9 +58,10 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
|
||||
- Cloud Web M3 只读护栏:`npm run web:m3-readonly`
|
||||
- Cloud Workbench 布局/遮挡 smoke:`npm run web:layout`;local-build 用 `npm run web:layout:build`;DEV deploy 后用 `npm run web:layout:live`。
|
||||
- DEV artifact 发布预检:`npm run dev-artifact:preflight`
|
||||
- DEV artifact CI 发布:`HWLAB_CI_ARTIFACT_RUN_ID=<run-id> npm run dev-artifact:publish`
|
||||
- DEV artifact CI 发布:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind ci-publish --concurrency 4`
|
||||
- DEV 依赖 runtime base 构建:`npm run dev-runtime-base:build`
|
||||
- DEV CD 单事务发布/应用/验证:`npm run dev-cd:apply`
|
||||
- DEV CD 单事务发布/应用/验证:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4`
|
||||
- CI/CD job 查询:`node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status|logs|report <jobId>`
|
||||
- runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight`
|
||||
- D601 k3s 只读观测:`npm run d601:k3s:readonly`
|
||||
- Gateway 主动出站本地 smoke:`npm run gateway:demo:smoke`;经本地 edge-proxy 验证用 `npm run gateway:demo:edge-smoke`。
|
||||
|
||||
@@ -43,18 +43,45 @@ truth.
|
||||
| Stage | Current HWLAB state | Required next behavior |
|
||||
| --- | --- | --- |
|
||||
| 1. Long-term reference | This file records DEV deployment, Cloud Web publish, API/edge/health, frp, D601 registry, k3s rollout, rollback, and verification rules. `AGENTS.md` indexes it. | Keep process issues as sources only; update this reference when a deployment rule changes. |
|
||||
| 2. CLI or controlled scripts | `scripts/dev-artifact-publish.mjs` is the formal CI artifact publish entrypoint under CI identity; `scripts/dev-cd-apply.mjs` is the formal DEV CD side-effect entrypoint for desired-state apply and live verify. Other controlled scripts still cover contract planning, dry-run planning, preflight, single-step internals, k3s read-only visibility, and Cloud Web checks. Rollback is documented as commands/report hints but is not yet one unified CLI command. | Converge check, plan, rollback, status, and verify into HWLAB CLI while preserving CI/CD separation and the single CD transaction lock backed by `deploy/deploy.json`. |
|
||||
| 2. CLI or controlled scripts | `hwlab-cli cicd submit/status/logs/report` 是正式 CI/CD 外层控制入口;`scripts/dev-artifact-publish.mjs` 和 `scripts/dev-cd-apply.mjs` 只作为后台 worker 或只读状态脚本使用。Other controlled scripts still cover contract planning, dry-run planning, preflight, single-step internals, k3s read-only visibility, and Cloud Web checks. Rollback is documented as commands/report hints but is not yet one unified CLI command. | Continue converging check, plan, rollback, status, and verify into HWLAB CLI while preserving CI/CD separation and the single CD transaction lock backed by `deploy/deploy.json`. |
|
||||
| 3. UniDesk CI/CD plus images | DEV services are imageized and published to the D601 registry. D601 rollout still includes manual bridge steps for Cloud Web and frp/k3s execution. | Move build, publish, desired-state refresh, rollout, post-deploy validation, and report upload into standard UniDesk CI/CD jobs without replacing HWLAB runtime. |
|
||||
|
||||
## CI/CD CLI 非阻塞合同
|
||||
|
||||
HWLAB CI/CD 正式 CLI 必须遵循 `Skill(cli-spec)`:可能超过短时间的 build、
|
||||
push、apply、rollout、runtime Job、smoke 和 postflight 都不得以前台阻塞命令
|
||||
暴露给指挥官、runner 或 master-side wrapper。正式入口必须使用
|
||||
Fire-and-Forget 模式:
|
||||
|
||||
```sh
|
||||
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind ci-publish --concurrency 4
|
||||
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4
|
||||
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status <jobId>
|
||||
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd logs <jobId> --tail-bytes 8000
|
||||
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd report <jobId>
|
||||
```
|
||||
|
||||
`submit` 只创建 `.state/hwlab-cicd/jobs/<jobId>/`,写入 `spec.json`、
|
||||
`state.json`、`stdout.log`、`stderr.log` 和 `report.json` 路径,然后启动后台
|
||||
worker 并立即返回有界 JSON。`status` 仅读 `state.json`,`logs` 默认只返回
|
||||
尾部摘要,`report` 默认只返回报告摘要;完整日志和完整报告通过返回的文件路径
|
||||
渐进查看。任何正式 CLI 调用都不得因为 Docker build、registry push、
|
||||
`kubectl wait --timeout` 或 rollout status 在前台长时间占用上下文。
|
||||
|
||||
`scripts/dev-artifact-publish.mjs`、`scripts/dev-cd-apply.mjs` 和其他内部脚本仍
|
||||
保留为 worker/backend:它们可以在后台 job 内执行长流程,但不能作为指挥面常规
|
||||
入口直接运行。临时手动排障可以直接调用底层脚本,但排障结论必须回填到正式
|
||||
CLI、长期参考文档或 issue,避免把一次性命令固化为新流程。
|
||||
|
||||
## DEV CD Transaction
|
||||
|
||||
正式 DEV CD 写路径只有一个入口:
|
||||
正式 DEV CD 写路径只有一个外层入口:
|
||||
|
||||
```sh
|
||||
node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json
|
||||
node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4
|
||||
```
|
||||
|
||||
该命令是唯一允许按 `deploy.json` 对 `hwlab-dev` 执行 desired-state apply、并复验
|
||||
该命令后台调用 `scripts/dev-cd-apply.mjs`,是唯一允许按 `deploy.json` 对 `hwlab-dev` 执行 desired-state apply、并复验
|
||||
公开 `16666/16667` 的正式 CD 写路径。镜像发布属于 CI artifact 职责;过渡期
|
||||
latest-main 兼容路径仍可由该命令调用 artifact publish,但正式 rollout 默认应消费
|
||||
已经发布并经过 registry manifest 验证的 artifact。
|
||||
@@ -150,7 +177,9 @@ repo-owned 入口,不能绕过它们直接拼接 publish、apply、rollout 或
|
||||
| 读取 DEV CD 状态 | `node scripts/dev-cd-apply.mjs --status` | 不写入、不获取 Lease、不读取 Secret value。 |
|
||||
| 事务 dry-run preflight | `node scripts/dev-cd-apply.mjs --dry-run` | 不 publish、不 apply、不 rollout。 |
|
||||
| 检查 desired-state 收敛 | `node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --check` | 只读 source/report。 |
|
||||
| 执行 DEV CD 事务 | `node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json` | 仅 DEV,在 `Lease/hwlab-dev/hwlab-dev-cd-lock` 下 apply/verify;legacy latest-main publish 仅作过渡兼容。 |
|
||||
| 提交 CI artifact publish | `node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind ci-publish --concurrency 4` | 立刻返回 jobId;后台 build/push,报告写入 `.state/hwlab-cicd/jobs/<jobId>/report.json`。 |
|
||||
| 提交 DEV CD 事务 | `node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4` | 立刻返回 jobId;后台在 `Lease/hwlab-dev/hwlab-dev-cd-lock` 下 apply/verify;legacy latest-main publish 仅作过渡兼容。 |
|
||||
| 查询 CI/CD job | `node tools/hwlab-cli/bin/hwlab-cli.mjs cicd status|logs|report <jobId>` | 只读 `.state/hwlab-cicd/jobs/<jobId>/`;日志和报告默认有界摘要。 |
|
||||
| 读取 D601 k3s 证据 | `node scripts/d601-k3s-readonly-observability.mjs` | 只读可见性报告;不 rollout、不输出 Secret value。 |
|
||||
|
||||
使用 master-side wrapper 时,稳定形态是:
|
||||
@@ -158,7 +187,7 @@ repo-owned 入口,不能绕过它们直接拼接 publish、apply、rollout 或
|
||||
```sh
|
||||
cd /root/unidesk
|
||||
bun scripts/cli.ts ssh D601 argv bash -lc 'cd <clean-hwlab-cd-worktree> && KUBECONFIG=/etc/rancher/k3s/k3s.yaml node scripts/dev-cd-apply.mjs --status'
|
||||
bun scripts/cli.ts ssh D601 argv bash -lc 'cd <clean-hwlab-cd-worktree> && KUBECONFIG=/etc/rancher/k3s/k3s.yaml node scripts/dev-cd-apply.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/report.json'
|
||||
bun scripts/cli.ts ssh D601 argv bash -lc 'cd <clean-hwlab-cd-worktree> && KUBECONFIG=/etc/rancher/k3s/k3s.yaml node tools/hwlab-cli/bin/hwlab-cli.mjs cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4'
|
||||
```
|
||||
|
||||
`<clean-hwlab-cd-worktree>` 必须指向干净工作区。该工作区可以是固定 clean
|
||||
|
||||
+4
-3
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
import { runCicdWorker } from "./cicd-jobs.mjs";
|
||||
|
||||
const jobId = process.argv[2];
|
||||
if (!jobId) {
|
||||
process.stderr.write(`${JSON.stringify({ ok: false, code: "missing-job-id", message: "usage: node tools/hwlab-cli/lib/cicd-job-worker.mjs <jobId>" }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.exitCode = await runCicdWorker({ repoRoot: process.cwd(), jobId });
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, open, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const stateRoot = ".state/hwlab-cicd/jobs";
|
||||
const knownKinds = new Set(["ci-publish", "dev-cd-apply"]);
|
||||
|
||||
function isoNow(now) {
|
||||
return (now ? now() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
function compactTimestamp(value) {
|
||||
return value.replace(/[-:.]/gu, "").slice(0, 15);
|
||||
}
|
||||
|
||||
function makeJobId(kind, now) {
|
||||
return `${kind}-${compactTimestamp(isoNow(now))}-${randomBytes(3).toString("hex")}`;
|
||||
}
|
||||
|
||||
function jobDir(repoRoot, jobId) {
|
||||
return path.join(repoRoot, stateRoot, jobId);
|
||||
}
|
||||
|
||||
function jobPaths(repoRoot, jobId) {
|
||||
const dir = jobDir(repoRoot, jobId);
|
||||
return {
|
||||
dir,
|
||||
specPath: path.join(dir, "spec.json"),
|
||||
statePath: path.join(dir, "state.json"),
|
||||
stdoutPath: path.join(dir, "stdout.log"),
|
||||
stderrPath: path.join(dir, "stderr.log"),
|
||||
reportPath: path.join(dir, "report.json")
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
async function writeJsonFile(filePath, value) {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function result(exitCode, payload, stream = "stdout") {
|
||||
return { exitCode, payload, stream };
|
||||
}
|
||||
|
||||
function error(code, message, details = {}) {
|
||||
return { ok: false, code, message, ...details };
|
||||
}
|
||||
|
||||
function followup(jobId) {
|
||||
return {
|
||||
status: `hwlab-cli cicd status ${jobId}`,
|
||||
logs: `hwlab-cli cicd logs ${jobId}`,
|
||||
report: `hwlab-cli cicd report ${jobId}`
|
||||
};
|
||||
}
|
||||
|
||||
function optionValue(options, name, fallback = null) {
|
||||
return options.get(name) ?? fallback;
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
if (value == null) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function buildJobSpec({ kind, flags, options, repoRoot, env, now }) {
|
||||
if (!knownKinds.has(kind)) {
|
||||
return { error: error("unsupported-cicd-kind", "Use --kind ci-publish or --kind dev-cd-apply.", { kind }) };
|
||||
}
|
||||
|
||||
const jobId = makeJobId(kind, now);
|
||||
const paths = jobPaths(repoRoot, jobId);
|
||||
const envPatch = {};
|
||||
const args = [];
|
||||
|
||||
if (kind === "ci-publish") {
|
||||
args.push("scripts/dev-artifact-publish.mjs", "--publish", "--report", paths.reportPath, "--quiet-build");
|
||||
const services = optionValue(options, "--services");
|
||||
if (services) args.push("--services", services);
|
||||
const concurrency = positiveInteger(optionValue(options, "--concurrency"), 4);
|
||||
args.push("--concurrency", String(concurrency));
|
||||
envPatch.HWLAB_CI_ARTIFACT_RUN_ID = optionValue(options, "--owner-task-id", jobId);
|
||||
envPatch.HWLAB_CI_ARTIFACT_RUN_OWNER = optionValue(options, "--owner", "hwlab-cli");
|
||||
} else {
|
||||
if (!flags.has("--confirm-dev") || !flags.has("--confirmed-non-production")) {
|
||||
return {
|
||||
error: error("confirmation-required", "DEV CD apply requires --confirm-dev --confirmed-non-production.", {
|
||||
requiredFlags: ["--confirm-dev", "--confirmed-non-production"]
|
||||
})
|
||||
};
|
||||
}
|
||||
args.push(
|
||||
"scripts/dev-cd-apply.mjs",
|
||||
"--apply",
|
||||
"--confirm-dev",
|
||||
"--confirmed-non-production",
|
||||
"--owner-task-id",
|
||||
optionValue(options, "--owner-task-id", jobId),
|
||||
"--report",
|
||||
paths.reportPath
|
||||
);
|
||||
if (flags.has("--break-stale-lock")) args.push("--break-stale-lock");
|
||||
const concurrency = optionValue(options, "--concurrency");
|
||||
if (concurrency) envPatch.HWLAB_DEV_CD_CONCURRENCY = String(positiveInteger(concurrency, 4));
|
||||
}
|
||||
|
||||
if (optionValue(options, "--target-ref")) args.push("--target-ref", optionValue(options, "--target-ref"));
|
||||
if (optionValue(options, "--kubeconfig")) args.push("--kubeconfig", optionValue(options, "--kubeconfig"));
|
||||
|
||||
return {
|
||||
spec: {
|
||||
jobId,
|
||||
kind,
|
||||
cwd: repoRoot,
|
||||
node: process.execPath,
|
||||
command: process.execPath,
|
||||
args,
|
||||
envPatch,
|
||||
createdAt: isoNow(now),
|
||||
host: os.hostname(),
|
||||
paths
|
||||
},
|
||||
paths,
|
||||
env
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultSpawnDetached({ repoRoot, jobId, env }) {
|
||||
const workerPath = path.join(repoRoot, "tools/hwlab-cli/lib/cicd-job-worker.mjs");
|
||||
const child = spawn(process.execPath, [workerPath, jobId], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: "ignore"
|
||||
});
|
||||
child.unref();
|
||||
return { pid: child.pid };
|
||||
}
|
||||
|
||||
async function submitJob(ctx) {
|
||||
const kind = optionValue(ctx.options, "--kind");
|
||||
const built = buildJobSpec({ ...ctx, kind });
|
||||
if (built.error) return result(1, built.error, "stderr");
|
||||
|
||||
await mkdir(built.paths.dir, { recursive: true });
|
||||
await writeJsonFile(built.paths.specPath, built.spec);
|
||||
const initialState = {
|
||||
ok: true,
|
||||
jobId: built.spec.jobId,
|
||||
kind: built.spec.kind,
|
||||
status: "submitted",
|
||||
nonBlocking: true,
|
||||
createdAt: built.spec.createdAt,
|
||||
updatedAt: built.spec.createdAt,
|
||||
cwd: built.spec.cwd,
|
||||
command: [path.basename(built.spec.command), ...built.spec.args].join(" "),
|
||||
statePath: built.paths.statePath,
|
||||
stdoutPath: built.paths.stdoutPath,
|
||||
stderrPath: built.paths.stderrPath,
|
||||
reportPath: built.paths.reportPath
|
||||
};
|
||||
await writeJsonFile(built.paths.statePath, initialState);
|
||||
|
||||
const launcher = ctx.spawnDetached ?? defaultSpawnDetached;
|
||||
const launched = await launcher({ repoRoot: ctx.repoRoot, jobId: built.spec.jobId, env: ctx.env, spec: built.spec });
|
||||
const submitted = {
|
||||
...initialState,
|
||||
pid: launched?.pid ?? null,
|
||||
followup: followup(built.spec.jobId)
|
||||
};
|
||||
await writeJsonFile(built.paths.statePath, submitted);
|
||||
return result(0, submitted);
|
||||
}
|
||||
|
||||
async function appendLog(filePath, line) {
|
||||
await writeFile(filePath, `${line}\n`, { flag: "a" });
|
||||
}
|
||||
|
||||
function reportSummary(report) {
|
||||
if (report?.artifactPublish) {
|
||||
return {
|
||||
kind: "ci-publish",
|
||||
status: report.artifactPublish.status ?? report.status ?? null,
|
||||
sourceCommitId: report.sourceCommitId ?? report.commitId ?? null,
|
||||
serviceCount: report.artifactPublish.serviceCount ?? null,
|
||||
requiredServiceCount: report.artifactPublish.requiredServiceCount ?? null,
|
||||
publishedCount: report.artifactPublish.publishedCount ?? null,
|
||||
timings: report.artifactPublish.timings ?? null,
|
||||
blockers: report.blockers ?? []
|
||||
};
|
||||
}
|
||||
if (report?.devCdApply) {
|
||||
return {
|
||||
kind: "dev-cd-apply",
|
||||
status: report.status ?? null,
|
||||
commitId: report.commitId ?? null,
|
||||
target: report.target ?? null,
|
||||
transaction: report.transaction
|
||||
? {
|
||||
transactionId: report.transaction.transactionId ?? null,
|
||||
ownerTaskId: report.transaction.ownerTaskId ?? null,
|
||||
phases: report.transaction.phases ?? [],
|
||||
release: report.transaction.release ?? null
|
||||
}
|
||||
: null,
|
||||
steps: report.devCdApply.steps?.map((step) => ({ id: step.id, status: step.status, reportPath: step.reportPath })) ?? [],
|
||||
blockers: report.blockers ?? []
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "generic",
|
||||
status: report?.status ?? report?.ok ?? null,
|
||||
keys: report && typeof report === "object" ? Object.keys(report).slice(0, 20) : []
|
||||
};
|
||||
}
|
||||
|
||||
async function runJob(ctx) {
|
||||
const jobId = ctx.rest[0];
|
||||
if (!jobId) return result(1, error("missing-job-id", "usage: hwlab-cli cicd run-job <jobId>"), "stderr");
|
||||
const paths = jobPaths(ctx.repoRoot, jobId);
|
||||
const spec = await readJson(paths.specPath);
|
||||
const startedAt = isoNow(ctx.now);
|
||||
await writeJsonFile(paths.statePath, {
|
||||
ok: true,
|
||||
jobId,
|
||||
kind: spec.kind,
|
||||
status: "running",
|
||||
nonBlocking: true,
|
||||
createdAt: spec.createdAt,
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
cwd: spec.cwd,
|
||||
pid: process.pid,
|
||||
command: [path.basename(spec.command), ...spec.args].join(" "),
|
||||
statePath: paths.statePath,
|
||||
stdoutPath: paths.stdoutPath,
|
||||
stderrPath: paths.stderrPath,
|
||||
reportPath: paths.reportPath
|
||||
});
|
||||
await appendLog(paths.stdoutPath, `[hwlab-cli] job ${jobId} started at ${startedAt}`);
|
||||
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: spec.cwd,
|
||||
env: { ...process.env, ...spec.envPatch },
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
const stdoutStream = createWriteStream(paths.stdoutPath, { flags: "a" });
|
||||
const stderrStream = createWriteStream(paths.stderrPath, { flags: "a" });
|
||||
child.stdout.pipe(stdoutStream);
|
||||
child.stderr.pipe(stderrStream);
|
||||
const exitCode = await new Promise((resolve) => {
|
||||
child.on("close", resolve);
|
||||
});
|
||||
stdoutStream.end();
|
||||
stderrStream.end();
|
||||
|
||||
const finishedAt = isoNow(ctx.now);
|
||||
const state = await readJson(paths.statePath);
|
||||
let summary = null;
|
||||
try {
|
||||
summary = reportSummary(await readJson(paths.reportPath));
|
||||
} catch {
|
||||
summary = null;
|
||||
}
|
||||
const finalState = {
|
||||
...state,
|
||||
status: exitCode === 0 ? "succeeded" : "failed",
|
||||
ok: exitCode === 0,
|
||||
exitCode,
|
||||
updatedAt: finishedAt,
|
||||
finishedAt,
|
||||
durationMs: Date.parse(finishedAt) - Date.parse(startedAt),
|
||||
reportSummary: summary,
|
||||
followup: followup(jobId)
|
||||
};
|
||||
await writeJsonFile(paths.statePath, finalState);
|
||||
return result(exitCode === 0 ? 0 : 2, finalState, exitCode === 0 ? "stdout" : "stderr");
|
||||
}
|
||||
|
||||
async function readState(repoRoot, jobId) {
|
||||
const paths = jobPaths(repoRoot, jobId);
|
||||
const state = await readJson(paths.statePath);
|
||||
return { paths, state };
|
||||
}
|
||||
|
||||
async function statusJob(ctx) {
|
||||
const jobId = ctx.rest[0];
|
||||
if (!jobId) return result(1, error("missing-job-id", "usage: hwlab-cli cicd status <jobId>"), "stderr");
|
||||
try {
|
||||
const { state } = await readState(ctx.repoRoot, jobId);
|
||||
return result(0, { ...state, followup: followup(jobId) });
|
||||
} catch (err) {
|
||||
return result(2, error("job-not-found", `No CI/CD job state found for ${jobId}.`, { detail: err.message }), "stderr");
|
||||
}
|
||||
}
|
||||
|
||||
async function tailFile(filePath, bytes) {
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
const length = Math.min(info.size, bytes);
|
||||
const handle = await open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, info.size - length);
|
||||
return { path: filePath, totalBytes: info.size, returnedBytes: length, truncated: info.size > length, text: buffer.toString("utf8") };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
return { path: filePath, totalBytes: 0, returnedBytes: 0, truncated: false, text: "" };
|
||||
}
|
||||
}
|
||||
|
||||
async function logsJob(ctx) {
|
||||
const jobId = ctx.rest[0];
|
||||
if (!jobId) return result(1, error("missing-job-id", "usage: hwlab-cli cicd logs <jobId>"), "stderr");
|
||||
const bytes = Math.min(positiveInteger(optionValue(ctx.options, "--tail-bytes"), 8000), 20000);
|
||||
try {
|
||||
const { paths, state } = await readState(ctx.repoRoot, jobId);
|
||||
return result(0, {
|
||||
ok: true,
|
||||
jobId,
|
||||
status: state.status,
|
||||
tailBytes: bytes,
|
||||
stdout: await tailFile(paths.stdoutPath, bytes),
|
||||
stderr: await tailFile(paths.stderrPath, bytes),
|
||||
followup: followup(jobId)
|
||||
});
|
||||
} catch (err) {
|
||||
return result(2, error("job-not-found", `No CI/CD job state found for ${jobId}.`, { detail: err.message }), "stderr");
|
||||
}
|
||||
}
|
||||
|
||||
async function reportJob(ctx) {
|
||||
const jobId = ctx.rest[0];
|
||||
if (!jobId) return result(1, error("missing-job-id", "usage: hwlab-cli cicd report <jobId>"), "stderr");
|
||||
try {
|
||||
const { paths, state } = await readState(ctx.repoRoot, jobId);
|
||||
const report = await readJson(paths.reportPath);
|
||||
return result(0, {
|
||||
ok: true,
|
||||
jobId,
|
||||
status: state.status,
|
||||
reportPath: paths.reportPath,
|
||||
summary: reportSummary(report),
|
||||
report: ctx.flags.has("--full") ? report : undefined,
|
||||
followup: followup(jobId)
|
||||
});
|
||||
} catch (err) {
|
||||
return result(2, error("report-not-ready", `No report is available for ${jobId}.`, { detail: err.message }), "stderr");
|
||||
}
|
||||
}
|
||||
|
||||
async function listJobs(ctx) {
|
||||
const root = path.join(ctx.repoRoot, stateRoot);
|
||||
const limit = Math.min(positiveInteger(optionValue(ctx.options, "--limit"), 20), 100);
|
||||
try {
|
||||
const names = (await readdir(root)).sort().reverse().slice(0, limit);
|
||||
const jobs = [];
|
||||
for (const name of names) {
|
||||
try {
|
||||
jobs.push((await readState(ctx.repoRoot, name)).state);
|
||||
} catch {
|
||||
// Ignore incomplete job directories; logs/report still remain on disk.
|
||||
}
|
||||
}
|
||||
return result(0, { ok: true, command: "hwlab-cli cicd jobs", count: jobs.length, jobs });
|
||||
} catch {
|
||||
return result(0, { ok: true, command: "hwlab-cli cicd jobs", count: 0, jobs: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCicdCommand(ctx) {
|
||||
if (ctx.subcommand === "submit") return submitJob(ctx);
|
||||
if (ctx.subcommand === "status") return statusJob(ctx);
|
||||
if (ctx.subcommand === "logs") return logsJob(ctx);
|
||||
if (ctx.subcommand === "report") return reportJob(ctx);
|
||||
if (ctx.subcommand === "jobs") return listJobs(ctx);
|
||||
return result(1, error("usage", "usage: hwlab-cli cicd submit --kind <ci-publish|dev-cd-apply> | status|logs|report <jobId> | jobs"), "stderr");
|
||||
}
|
||||
|
||||
export async function runCicdWorker({ repoRoot = process.cwd(), jobId, now }) {
|
||||
const outcome = await runJob({
|
||||
repoRoot,
|
||||
rest: [jobId],
|
||||
now
|
||||
});
|
||||
return outcome.exitCode;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEV_ENDPOINT, loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs";
|
||||
import { runM3IoSkillCommand } from "../../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
||||
import { runCicdCommand } from "./cicd-jobs.mjs";
|
||||
|
||||
function formatJson(value) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
@@ -173,6 +174,12 @@ function writeHelp(stdout) {
|
||||
"m3 status --api-base-url URL",
|
||||
"m3 io --action do.write --value true --approved --api-base-url URL",
|
||||
"m3 io --action di.read --api-base-url URL",
|
||||
"cicd submit --kind ci-publish --concurrency 4",
|
||||
"cicd submit --kind dev-cd-apply --confirm-dev --confirmed-non-production --concurrency 4",
|
||||
"cicd status JOB_ID",
|
||||
"cicd logs JOB_ID --tail-bytes 8000",
|
||||
"cicd report JOB_ID",
|
||||
"cicd jobs --limit 20",
|
||||
"test e2e --env dev --mvp",
|
||||
"test e2e --env dev --mvp --dry-run",
|
||||
"test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production"
|
||||
@@ -194,6 +201,21 @@ export async function runCli(argv, io) {
|
||||
const [command = "help", subcommand, ...rest] = argv;
|
||||
const { flags, options } = parseArgs(rest);
|
||||
|
||||
if (command === "cicd") {
|
||||
const outcome = await runCicdCommand({
|
||||
subcommand,
|
||||
rest,
|
||||
flags,
|
||||
options,
|
||||
repoRoot,
|
||||
env,
|
||||
now: io.now,
|
||||
spawnDetached: io.spawnDetached
|
||||
});
|
||||
writeJson(outcome.stream === "stderr" ? stderr : stdout, outcome.payload);
|
||||
return outcome.exitCode;
|
||||
}
|
||||
|
||||
if (command === "m3" && ["io", "status"].includes(subcommand)) {
|
||||
const result = await runM3IoSkillCommand(["m3", subcommand, ...rest], {
|
||||
env,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
@@ -16,6 +19,7 @@ async function captureCli(args, options = {}) {
|
||||
PATH: process.env.PATH
|
||||
},
|
||||
requestJson: options.requestJson,
|
||||
spawnDetached: options.spawnDetached,
|
||||
stdout: {
|
||||
write(chunk) {
|
||||
stdout += chunk;
|
||||
@@ -30,6 +34,82 @@ async function captureCli(args, options = {}) {
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
test("repo hwlab-cli cicd submit is non-blocking and records follow-up commands", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-cicd-"));
|
||||
const launched = [];
|
||||
const result = await captureCli(
|
||||
["cicd", "submit", "--kind", "ci-publish", "--services", "hwlab-cloud-web", "--concurrency", "2"],
|
||||
{
|
||||
cwd,
|
||||
spawnDetached: async (job) => {
|
||||
launched.push(job);
|
||||
return { pid: 123456 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.nonBlocking, true);
|
||||
assert.equal(body.kind, "ci-publish");
|
||||
assert.match(body.jobId, /^ci-publish-/u);
|
||||
assert.equal(body.pid, 123456);
|
||||
assert.equal(body.followup.status, `hwlab-cli cicd status ${body.jobId}`);
|
||||
assert.equal(launched.length, 1);
|
||||
|
||||
const spec = JSON.parse(await readFile(path.join(cwd, ".state", "hwlab-cicd", "jobs", body.jobId, "spec.json"), "utf8"));
|
||||
assert.deepEqual(spec.args.slice(0, 4), ["scripts/dev-artifact-publish.mjs", "--publish", "--report", body.reportPath]);
|
||||
assert.ok(spec.args.includes("--quiet-build"));
|
||||
assert.equal(spec.envPatch.HWLAB_CI_ARTIFACT_RUN_ID, body.jobId);
|
||||
});
|
||||
|
||||
test("repo hwlab-cli cicd status/logs/report disclose bounded job state", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-cicd-"));
|
||||
const submit = await captureCli(["cicd", "submit", "--kind", "ci-publish"], {
|
||||
cwd,
|
||||
spawnDetached: async () => ({ pid: 123457 })
|
||||
});
|
||||
const submitted = JSON.parse(submit.stdout);
|
||||
const jobDir = path.join(cwd, ".state", "hwlab-cicd", "jobs", submitted.jobId);
|
||||
await writeFile(path.join(jobDir, "stdout.log"), "build line 1\nbuild line 2\n", "utf8");
|
||||
await writeFile(path.join(jobDir, "stderr.log"), "warning line\n", "utf8");
|
||||
await writeFile(
|
||||
path.join(jobDir, "report.json"),
|
||||
`${JSON.stringify({
|
||||
sourceCommitId: "abc123",
|
||||
artifactPublish: {
|
||||
status: "published",
|
||||
serviceCount: 1,
|
||||
requiredServiceCount: 1,
|
||||
publishedCount: 1,
|
||||
timings: { durationMs: 42 }
|
||||
},
|
||||
blockers: []
|
||||
})}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const status = await captureCli(["cicd", "status", submitted.jobId], { cwd });
|
||||
assert.equal(status.exitCode, 0, status.stderr);
|
||||
assert.equal(JSON.parse(status.stdout).status, "submitted");
|
||||
|
||||
const logs = await captureCli(["cicd", "logs", submitted.jobId, "--tail-bytes", "12"], { cwd });
|
||||
assert.equal(logs.exitCode, 0, logs.stderr);
|
||||
const logBody = JSON.parse(logs.stdout);
|
||||
assert.equal(logBody.stdout.returnedBytes, 12);
|
||||
assert.equal(logBody.stdout.truncated, true);
|
||||
assert.ok(logBody.stdout.text.includes("line 2"));
|
||||
|
||||
const report = await captureCli(["cicd", "report", submitted.jobId], { cwd });
|
||||
assert.equal(report.exitCode, 0, report.stderr);
|
||||
const reportBody = JSON.parse(report.stdout);
|
||||
assert.equal(reportBody.summary.kind, "ci-publish");
|
||||
assert.equal(reportBody.summary.status, "published");
|
||||
assert.equal(reportBody.summary.publishedCount, 1);
|
||||
assert.equal(Object.hasOwn(reportBody, "report"), false);
|
||||
});
|
||||
|
||||
test("repo hwlab-cli m3 status uses Skill CLI and calls only HWLAB API /v1/m3/status", async () => {
|
||||
const calls = [];
|
||||
const result = await captureCli(
|
||||
@@ -104,4 +184,3 @@ test("repo hwlab-cli m3 io blocks direct hardware targets before request", async
|
||||
assert.equal(body.hwlabApi.redactedUrl, null);
|
||||
assert.equal(JSON.stringify(body).includes("patch-panel.hwlab-dev"), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"hwlab-cli": "./bin/hwlab-cli.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"check": "node --check bin/hwlab-cli.mjs && node --check lib/cli.mjs && node ../../scripts/l6-cli-web-smoke.mjs",
|
||||
"check": "node --check bin/hwlab-cli.mjs && node --check lib/cli.mjs && node --check lib/cicd-jobs.mjs && node --check lib/cicd-job-worker.mjs && node --test lib/cli.test.mjs && node ../../scripts/l6-cli-web-smoke.mjs",
|
||||
"health": "node bin/hwlab-cli.mjs health"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user