feat: add YAML-first source workspace host deps
This commit is contained in:
@@ -787,12 +787,39 @@ lanes:
|
||||
requiredCommands:
|
||||
- git
|
||||
- node
|
||||
- npm
|
||||
- npx
|
||||
requiredFiles:
|
||||
- AGENTS.md
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- scripts/src/browser-launcher.mjs
|
||||
- scripts/web-live-dom-probe.mjs
|
||||
hostDependencies:
|
||||
checkCommands:
|
||||
- git
|
||||
- node
|
||||
- npm
|
||||
- npx
|
||||
stateDir: /var/lib/unidesk/hwlab-source-workspace
|
||||
install:
|
||||
timeoutSeconds: 300
|
||||
command: |
|
||||
set -eu
|
||||
. /etc/unidesk/proxy.env 2>/dev/null || true
|
||||
export HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy
|
||||
npm_version=11.17.0
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
curl -fsSL --retry 3 --connect-timeout 20 --max-time 300 "https://registry.npmmirror.com/npm/-/npm-${npm_version}.tgz" -o "$tmp_dir/npm.tgz"
|
||||
mkdir -p /usr/local/lib/node_modules
|
||||
rm -rf /usr/local/lib/node_modules/npm
|
||||
tar -xzf "$tmp_dir/npm.tgz" -C "$tmp_dir"
|
||||
mv "$tmp_dir/package" /usr/local/lib/node_modules/npm
|
||||
ln -sfn /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm
|
||||
ln -sfn /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
npm --version
|
||||
npx --version
|
||||
install:
|
||||
dependencyCommand: npm install --package-lock=false --ignore-scripts --no-audit --no-fund playwright@1.59.1
|
||||
browserCommand: PLAYWRIGHT_BROWSERS_PATH=0 npx playwright install chromium
|
||||
|
||||
@@ -354,6 +354,7 @@ export interface HwlabRuntimeCodeAgentRuntimeSpec {
|
||||
export interface HwlabRuntimeSourceWorkspaceSpec {
|
||||
readonly requiredCommands: readonly string[];
|
||||
readonly requiredFiles: readonly string[];
|
||||
readonly hostDependencies?: HwlabRuntimeSourceWorkspaceHostDependenciesSpec;
|
||||
readonly install: {
|
||||
readonly dependencyCommand: string;
|
||||
readonly browserCommand: string;
|
||||
@@ -361,6 +362,15 @@ export interface HwlabRuntimeSourceWorkspaceSpec {
|
||||
};
|
||||
}
|
||||
|
||||
export interface HwlabRuntimeSourceWorkspaceHostDependenciesSpec {
|
||||
readonly checkCommands: readonly string[];
|
||||
readonly stateDir: string;
|
||||
readonly install: {
|
||||
readonly command: string;
|
||||
readonly timeoutSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HwlabRuntimeLaneSpec {
|
||||
readonly lane: HwlabRuntimeLane;
|
||||
readonly nodeId: string;
|
||||
@@ -852,6 +862,14 @@ function relativeWorkspacePathField(value: string, path: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function absoluteHostPathField(value: string, path: string): string {
|
||||
const parts = value.slice(1).split("/");
|
||||
if (!value.startsWith("/") || value.includes("\0") || parts.some((part) => part === ".." || part === "")) {
|
||||
throw new Error(`${path} must be an absolute host path without empty components or ..`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceWorkspaceConfig(value: unknown, path: string): HwlabRuntimeSourceWorkspaceSpec | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const raw = asRecord(value, path);
|
||||
@@ -861,6 +879,7 @@ function sourceWorkspaceConfig(value: unknown, path: string): HwlabRuntimeSource
|
||||
.map((item, index) => commandNameField(item, `${path}.requiredCommands[${index}]`)),
|
||||
requiredFiles: stringArrayField(raw, "requiredFiles", path)
|
||||
.map((item, index) => relativeWorkspacePathField(item, `${path}.requiredFiles[${index}]`)),
|
||||
hostDependencies: sourceWorkspaceHostDependenciesConfig(raw.hostDependencies, `${path}.hostDependencies`),
|
||||
install: {
|
||||
dependencyCommand: stringField(install, "dependencyCommand", `${path}.install`),
|
||||
browserCommand: stringField(install, "browserCommand", `${path}.install`),
|
||||
@@ -869,6 +888,21 @@ function sourceWorkspaceConfig(value: unknown, path: string): HwlabRuntimeSource
|
||||
};
|
||||
}
|
||||
|
||||
function sourceWorkspaceHostDependenciesConfig(value: unknown, path: string): HwlabRuntimeSourceWorkspaceHostDependenciesSpec | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const raw = asRecord(value, path);
|
||||
const install = asRecord(raw.install, `${path}.install`);
|
||||
return {
|
||||
checkCommands: stringArrayField(raw, "checkCommands", path)
|
||||
.map((item, index) => commandNameField(item, `${path}.checkCommands[${index}]`)),
|
||||
stateDir: absoluteHostPathField(stringField(raw, "stateDir", path), `${path}.stateDir`),
|
||||
install: {
|
||||
command: stringField(install, "command", `${path}.install`),
|
||||
timeoutSeconds: numberField(install, "timeoutSeconds", `${path}.install`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function externalPostgresComponentConfig(value: unknown, path: string): HwlabRuntimeExternalPostgresComponentSpec {
|
||||
const raw = asRecord(value, path);
|
||||
return {
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { posix as posixPath } from "node:path";
|
||||
import { hwlabNodeControlPlaneSourceWorkspaceBootstrap } from "../hwlab-node-control-plane";
|
||||
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec, type HwlabRuntimeSourceWorkspaceSpec } from "../hwlab-node-lanes";
|
||||
import { hwlabRuntimeLaneConfigPath, type HwlabRuntimeLaneSpec, type HwlabRuntimeSourceWorkspaceHostDependenciesSpec, type HwlabRuntimeSourceWorkspaceSpec } from "../hwlab-node-lanes";
|
||||
import { parseNodeScopedDelegatedOptions } from "./plan";
|
||||
import { runTransHostScript, runTransScript } from "./public-exposure";
|
||||
import { compactRuntimeCommand, parseLastJsonLineObject } from "./runtime-common";
|
||||
import { commaListField, keyValueLinesFromText, numericField, shellQuote, statusText } from "./utils";
|
||||
|
||||
type ScopedNodeOptions = ReturnType<typeof parseNodeScopedDelegatedOptions>;
|
||||
type SourceWorkspaceAction = "status" | "bootstrap";
|
||||
type SourceWorkspaceAction = "status" | "bootstrap" | "host-deps";
|
||||
type SourceWorkspaceHostDependenciesAction = "status" | "sync";
|
||||
|
||||
export function nodeRuntimeSourceWorkspaceCommand(scoped: ScopedNodeOptions): Record<string, unknown> {
|
||||
const action = sourceWorkspaceAction(scoped);
|
||||
@@ -27,14 +28,15 @@ export function nodeRuntimeSourceWorkspaceCommand(scoped: ScopedNodeOptions): Re
|
||||
configPath: hwlabRuntimeLaneConfigPath(),
|
||||
};
|
||||
}
|
||||
if (action === "host-deps") return nodeRuntimeSourceWorkspaceHostDependencies(scoped, sourceWorkspace);
|
||||
if (action === "status") return nodeRuntimeSourceWorkspaceStatus(scoped, sourceWorkspace);
|
||||
return nodeRuntimeSourceWorkspaceBootstrap(scoped, sourceWorkspace);
|
||||
}
|
||||
|
||||
function sourceWorkspaceAction(scoped: ScopedNodeOptions): SourceWorkspaceAction {
|
||||
const value = scoped.originalArgs[1];
|
||||
if (value !== "status" && value !== "bootstrap") {
|
||||
throw new Error("control-plane source-workspace usage: source-workspace status|bootstrap --node NODE --lane vNN [--dry-run|--confirm]");
|
||||
if (value !== "status" && value !== "bootstrap" && value !== "host-deps") {
|
||||
throw new Error("control-plane source-workspace usage: source-workspace status|bootstrap|host-deps --node NODE --lane vNN [--dry-run|--confirm]");
|
||||
}
|
||||
if (value === "status" && (scoped.confirm || scoped.dryRun)) {
|
||||
throw new Error("control-plane source-workspace status is read-only and does not accept --dry-run or --confirm");
|
||||
@@ -42,6 +44,17 @@ function sourceWorkspaceAction(scoped: ScopedNodeOptions): SourceWorkspaceAction
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceWorkspaceHostDependenciesAction(scoped: ScopedNodeOptions): SourceWorkspaceHostDependenciesAction {
|
||||
const value = scoped.originalArgs[2];
|
||||
if (value !== "status" && value !== "sync") {
|
||||
throw new Error("control-plane source-workspace host-deps usage: source-workspace host-deps status|sync --node NODE --lane vNN [--dry-run|--confirm] [--wait]");
|
||||
}
|
||||
if (value === "status" && (scoped.confirm || scoped.dryRun || scoped.wait)) {
|
||||
throw new Error("control-plane source-workspace host-deps status is read-only and does not accept --dry-run, --confirm or --wait");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nodeRuntimeSourceWorkspaceStatus(scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Record<string, unknown> {
|
||||
const result = runTransHostScript(scoped.node, sourceWorkspaceStatusScript(scoped.spec, sourceWorkspace), "", scoped.timeoutSeconds);
|
||||
const fields = keyValueLinesFromText(statusText(result));
|
||||
@@ -97,6 +110,175 @@ function nodeRuntimeSourceWorkspaceBootstrap(scoped: ScopedNodeOptions, sourceWo
|
||||
};
|
||||
}
|
||||
|
||||
function nodeRuntimeSourceWorkspaceHostDependencies(scoped: ScopedNodeOptions, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Record<string, unknown> {
|
||||
const action = sourceWorkspaceHostDependenciesAction(scoped);
|
||||
const hostDependencies = sourceWorkspace.hostDependencies;
|
||||
if (hostDependencies === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
command: `hwlab nodes control-plane source-workspace host-deps ${action} --node ${scoped.node} --lane ${scoped.lane}`,
|
||||
node: scoped.node,
|
||||
lane: scoped.lane,
|
||||
mutation: false,
|
||||
configPath: hwlabRuntimeLaneConfigPath(),
|
||||
degradedReason: "source-workspace-host-dependencies-yaml-missing",
|
||||
message: "lanes.<lane>.targets.<node>.sourceWorkspace.hostDependencies must declare checkCommands, stateDir and install before this controlled entry can run.",
|
||||
};
|
||||
}
|
||||
if (action === "status") return nodeRuntimeSourceWorkspaceHostDependenciesStatus(scoped, hostDependencies);
|
||||
return nodeRuntimeSourceWorkspaceHostDependenciesSync(scoped, sourceWorkspace, hostDependencies);
|
||||
}
|
||||
|
||||
function nodeRuntimeSourceWorkspaceHostDependenciesStatus(scoped: ScopedNodeOptions, hostDependencies: HwlabRuntimeSourceWorkspaceHostDependenciesSpec): Record<string, unknown> {
|
||||
const result = runTransHostScript(scoped.node, sourceWorkspaceHostDependenciesStatusScript(hostDependencies), "", scoped.timeoutSeconds);
|
||||
const payload = parseLastJsonLineObject(statusText(result));
|
||||
const ok = result.exitCode === 0 && payload.ok === true;
|
||||
return {
|
||||
ok,
|
||||
command: `hwlab nodes control-plane source-workspace host-deps status --node ${scoped.node} --lane ${scoped.lane}`,
|
||||
node: scoped.node,
|
||||
lane: scoped.lane,
|
||||
mode: "read-only",
|
||||
mutation: false,
|
||||
configPath: hwlabRuntimeLaneConfigPath(),
|
||||
expected: sourceWorkspaceHostDependenciesExpected(hostDependencies),
|
||||
status: payload,
|
||||
probe: compactRuntimeCommand(result),
|
||||
degradedReason: ok ? undefined : "source-workspace-host-dependencies-not-ready",
|
||||
next: ok ? undefined : { sync: `bun scripts/cli.ts hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane} --confirm --wait` },
|
||||
};
|
||||
}
|
||||
|
||||
function nodeRuntimeSourceWorkspaceHostDependenciesSync(
|
||||
scoped: ScopedNodeOptions,
|
||||
sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec,
|
||||
hostDependencies: HwlabRuntimeSourceWorkspaceHostDependenciesSpec,
|
||||
): Record<string, unknown> {
|
||||
if (scoped.confirm && scoped.dryRun) throw new Error("control-plane source-workspace host-deps sync accepts only one of --dry-run or --confirm");
|
||||
const dryRun = scoped.dryRun || !scoped.confirm;
|
||||
const before = nodeRuntimeSourceWorkspaceHostDependenciesStatus({ ...scoped, originalArgs: ["source-workspace", "host-deps", "status", "--node", scoped.node, "--lane", scoped.lane], confirm: false, dryRun: false, wait: false }, hostDependencies);
|
||||
if (before.ok === true) {
|
||||
return {
|
||||
ok: true,
|
||||
command: `hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane}`,
|
||||
node: scoped.node,
|
||||
lane: scoped.lane,
|
||||
mode: dryRun ? "dry-run" : "already-ready",
|
||||
mutation: false,
|
||||
configPath: hwlabRuntimeLaneConfigPath(),
|
||||
expected: sourceWorkspaceHostDependenciesExpected(hostDependencies),
|
||||
before,
|
||||
sync: { ok: true, status: "skipped", reason: "host-dependencies-already-ready", valuesPrinted: false },
|
||||
after: before,
|
||||
next: { status: `bun scripts/cli.ts hwlab nodes control-plane source-workspace status --node ${scoped.node} --lane ${scoped.lane}` },
|
||||
};
|
||||
}
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
command: `hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane}`,
|
||||
node: scoped.node,
|
||||
lane: scoped.lane,
|
||||
mode: "dry-run",
|
||||
mutation: false,
|
||||
configPath: hwlabRuntimeLaneConfigPath(),
|
||||
expected: sourceWorkspaceHostDependenciesExpected(hostDependencies),
|
||||
before,
|
||||
sync: {
|
||||
ok: true,
|
||||
status: "dry-run",
|
||||
stateDir: hostDependencies.stateDir,
|
||||
checkCommands: hostDependencies.checkCommands,
|
||||
installCommandSha256: createHash("sha256").update(hostDependencies.install.command).digest("hex"),
|
||||
timeoutSeconds: hostDependencies.install.timeoutSeconds,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
after: null,
|
||||
next: { confirm: `bun scripts/cli.ts hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane} --confirm --wait` },
|
||||
};
|
||||
}
|
||||
const submit = runTransHostScript(scoped.node, sourceWorkspaceHostDependenciesSubmitScript(scoped.spec, sourceWorkspace, hostDependencies), "", Math.min(scoped.timeoutSeconds, 55));
|
||||
const submitted = parseLastJsonLineObject(statusText(submit));
|
||||
if (submit.exitCode !== 0 || submitted.ok !== true) {
|
||||
return {
|
||||
ok: false,
|
||||
command: `hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane}`,
|
||||
node: scoped.node,
|
||||
lane: scoped.lane,
|
||||
mode: "confirmed-submit",
|
||||
mutation: false,
|
||||
configPath: hwlabRuntimeLaneConfigPath(),
|
||||
expected: sourceWorkspaceHostDependenciesExpected(hostDependencies),
|
||||
before,
|
||||
sync: submitted,
|
||||
result: compactRuntimeCommand(submit),
|
||||
after: null,
|
||||
degradedReason: "source-workspace-host-dependencies-submit-failed",
|
||||
next: { retry: `bun scripts/cli.ts hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane} --confirm --wait` },
|
||||
};
|
||||
}
|
||||
if (!scoped.wait) {
|
||||
return {
|
||||
ok: true,
|
||||
command: `hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane}`,
|
||||
node: scoped.node,
|
||||
lane: scoped.lane,
|
||||
mode: "submitted",
|
||||
mutation: true,
|
||||
configPath: hwlabRuntimeLaneConfigPath(),
|
||||
expected: sourceWorkspaceHostDependenciesExpected(hostDependencies),
|
||||
before,
|
||||
sync: submitted,
|
||||
result: compactRuntimeCommand(submit),
|
||||
after: null,
|
||||
next: { status: `bun scripts/cli.ts hwlab nodes control-plane source-workspace host-deps status --node ${scoped.node} --lane ${scoped.lane}` },
|
||||
};
|
||||
}
|
||||
const waited = waitForSourceWorkspaceHostDependencies(scoped, hostDependencies);
|
||||
const after = nodeRuntimeSourceWorkspaceHostDependenciesStatus({ ...scoped, originalArgs: ["source-workspace", "host-deps", "status", "--node", scoped.node, "--lane", scoped.lane], confirm: false, dryRun: false, wait: false }, hostDependencies);
|
||||
const ok = waited.ok === true && after.ok === true;
|
||||
return {
|
||||
ok,
|
||||
command: `hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane}`,
|
||||
node: scoped.node,
|
||||
lane: scoped.lane,
|
||||
mode: "confirmed-wait",
|
||||
mutation: true,
|
||||
configPath: hwlabRuntimeLaneConfigPath(),
|
||||
expected: sourceWorkspaceHostDependenciesExpected(hostDependencies),
|
||||
before,
|
||||
sync: submitted,
|
||||
result: compactRuntimeCommand(submit),
|
||||
wait: waited,
|
||||
after,
|
||||
degradedReason: ok ? undefined : "source-workspace-host-dependencies-sync-failed",
|
||||
next: ok
|
||||
? { status: `bun scripts/cli.ts hwlab nodes control-plane source-workspace status --node ${scoped.node} --lane ${scoped.lane}` }
|
||||
: { retry: `bun scripts/cli.ts hwlab nodes control-plane source-workspace host-deps sync --node ${scoped.node} --lane ${scoped.lane} --confirm --wait` },
|
||||
};
|
||||
}
|
||||
|
||||
function waitForSourceWorkspaceHostDependencies(scoped: ScopedNodeOptions, hostDependencies: HwlabRuntimeSourceWorkspaceHostDependenciesSpec): Record<string, unknown> {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + (hostDependencies.install.timeoutSeconds + 60) * 1000;
|
||||
let polls = 0;
|
||||
let lastPayload: Record<string, unknown> = { ok: false, status: "not-polled", valuesPrinted: false };
|
||||
while (Date.now() <= deadline) {
|
||||
polls += 1;
|
||||
const status = nodeRuntimeSourceWorkspaceHostDependenciesStatus({ ...scoped, originalArgs: ["source-workspace", "host-deps", "status", "--node", scoped.node, "--lane", scoped.lane], confirm: false, dryRun: false, wait: false }, hostDependencies);
|
||||
lastPayload = typeof status.status === "object" && status.status !== null ? status.status as Record<string, unknown> : lastPayload;
|
||||
if (status.ok === true) return { ok: true, status: "succeeded", polls, elapsedMs: Date.now() - startedAt, lastStatus: lastPayload, valuesPrinted: false };
|
||||
const lastInstall = typeof lastPayload.lastInstall === "object" && lastPayload.lastInstall !== null ? lastPayload.lastInstall as Record<string, unknown> : {};
|
||||
if (lastInstall.status === "failed") return { ok: false, status: "failed", polls, elapsedMs: Date.now() - startedAt, lastStatus: lastPayload, valuesPrinted: false };
|
||||
sleepMs(5000);
|
||||
}
|
||||
return { ok: false, status: "timeout", polls, elapsedMs: Date.now() - startedAt, lastStatus: lastPayload, valuesPrinted: false };
|
||||
}
|
||||
|
||||
function sleepMs(ms: number): void {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function sourceWorkspaceExpected(spec: HwlabRuntimeLaneSpec, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): Record<string, unknown> {
|
||||
return {
|
||||
workspace: spec.workspace,
|
||||
@@ -108,6 +290,7 @@ function sourceWorkspaceExpected(spec: HwlabRuntimeLaneSpec, sourceWorkspace: Hw
|
||||
},
|
||||
requiredCommands: sourceWorkspace.requiredCommands,
|
||||
requiredFiles: sourceWorkspace.requiredFiles,
|
||||
hostDependencies: sourceWorkspace.hostDependencies === undefined ? null : sourceWorkspaceHostDependenciesExpected(sourceWorkspace.hostDependencies),
|
||||
install: sourceWorkspace.install,
|
||||
downloadProfile: {
|
||||
id: spec.downloadProfileId,
|
||||
@@ -121,6 +304,17 @@ function sourceWorkspaceExpected(spec: HwlabRuntimeLaneSpec, sourceWorkspace: Hw
|
||||
};
|
||||
}
|
||||
|
||||
function sourceWorkspaceHostDependenciesExpected(hostDependencies: HwlabRuntimeSourceWorkspaceHostDependenciesSpec): Record<string, unknown> {
|
||||
return {
|
||||
checkCommands: hostDependencies.checkCommands,
|
||||
stateDir: hostDependencies.stateDir,
|
||||
install: {
|
||||
commandSha256: createHash("sha256").update(hostDependencies.install.command).digest("hex"),
|
||||
timeoutSeconds: hostDependencies.install.timeoutSeconds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sourceWorkspaceStatusFromFields(fields: Record<string, string>, exitCode: number | null): Record<string, unknown> {
|
||||
const missingCommands = commaListField(fields.missingCommands);
|
||||
const missingFiles = commaListField(fields.missingFiles);
|
||||
@@ -168,6 +362,116 @@ function sourceWorkspaceStatusFromFields(fields: Record<string, string>, exitCod
|
||||
};
|
||||
}
|
||||
|
||||
function sourceWorkspaceHostDependenciesStatusScript(hostDependencies: HwlabRuntimeSourceWorkspaceHostDependenciesSpec): string {
|
||||
const checkCommands = hostDependencies.checkCommands.join("\n");
|
||||
return [
|
||||
"set +e",
|
||||
`state_dir=${shellQuote(hostDependencies.stateDir)}`,
|
||||
`check_commands_b64=${shellQuote(Buffer.from(checkCommands, "utf8").toString("base64"))}`,
|
||||
"mkdir -p \"$state_dir\" 2>/tmp/hwlab-source-workspace-host-deps-mkdir.err",
|
||||
"mkdir_exit=$?",
|
||||
"commands_file=$(mktemp)",
|
||||
"trap 'rm -f \"$commands_file\"' EXIT",
|
||||
"printf '%s' \"$check_commands_b64\" | base64 -d >\"$commands_file\"",
|
||||
"missing_commands=",
|
||||
"present_commands=",
|
||||
"while IFS= read -r command_name; do",
|
||||
" [ -z \"$command_name\" ] && continue",
|
||||
" if command -v \"$command_name\" >/dev/null 2>&1; then",
|
||||
" present_commands=\"$present_commands${present_commands:+,}$command_name\"",
|
||||
" else",
|
||||
" missing_commands=\"$missing_commands${missing_commands:+,}$command_name\"",
|
||||
" fi",
|
||||
"done <\"$commands_file\"",
|
||||
"status_file=\"$state_dir/install-status.json\"",
|
||||
"last_install_json='{}'",
|
||||
"if [ -f \"$status_file\" ]; then last_install_json=$(tail -c 20000 \"$status_file\" 2>/dev/null || printf '{}'); fi",
|
||||
"STATE_DIR=\"$state_dir\" MKDIR_EXIT=\"$mkdir_exit\" MISSING_COMMANDS=\"$missing_commands\" PRESENT_COMMANDS=\"$present_commands\" STATUS_FILE=\"$status_file\" LAST_INSTALL_JSON=\"$last_install_json\" node <<'NODE'",
|
||||
"const lastRaw = process.env.LAST_INSTALL_JSON || '{}';",
|
||||
"let lastInstall = {};",
|
||||
"try { lastInstall = JSON.parse(lastRaw); } catch { lastInstall = { ok: false, status: 'unparseable', valuesPrinted: false }; }",
|
||||
"const missing = (process.env.MISSING_COMMANDS || '').split(',').filter(Boolean);",
|
||||
"const present = (process.env.PRESENT_COMMANDS || '').split(',').filter(Boolean);",
|
||||
"const mkdirExit = Number(process.env.MKDIR_EXIT || '1');",
|
||||
"console.log(JSON.stringify({ ok: mkdirExit === 0 && missing.length === 0, status: mkdirExit === 0 && missing.length === 0 ? 'ready' : 'missing', stateDir: process.env.STATE_DIR, statusFile: process.env.STATUS_FILE, presentCommands: present, missingCommands: missing, mkdirExitCode: mkdirExit, lastInstall, valuesPrinted: false }));",
|
||||
"NODE",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sourceWorkspaceHostDependenciesSubmitScript(
|
||||
spec: HwlabRuntimeLaneSpec,
|
||||
sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec,
|
||||
hostDependencies: HwlabRuntimeSourceWorkspaceHostDependenciesSpec,
|
||||
): string {
|
||||
const checkCommands = hostDependencies.checkCommands.join("\n");
|
||||
const noProxy = spec.networkProfile.proxy.noProxy.join(",");
|
||||
const commandSha = createHash("sha256").update(hostDependencies.install.command).digest("hex");
|
||||
const requiredCommandsSha = createHash("sha256").update(sourceWorkspace.requiredCommands.join("\n")).digest("hex");
|
||||
return [
|
||||
"set +e",
|
||||
`state_dir=${shellQuote(hostDependencies.stateDir)}`,
|
||||
`install_command_b64=${shellQuote(Buffer.from(hostDependencies.install.command, "utf8").toString("base64"))}`,
|
||||
`check_commands_b64=${shellQuote(Buffer.from(checkCommands, "utf8").toString("base64"))}`,
|
||||
`timeout_seconds=${String(hostDependencies.install.timeoutSeconds)}`,
|
||||
`http_proxy_value=${shellQuote(spec.networkProfile.proxy.http)}`,
|
||||
`https_proxy_value=${shellQuote(spec.networkProfile.proxy.https)}`,
|
||||
`all_proxy_value=${shellQuote(spec.networkProfile.proxy.all)}`,
|
||||
`no_proxy_value=${shellQuote(noProxy)}`,
|
||||
`command_sha=${shellQuote(commandSha)}`,
|
||||
`required_commands_sha=${shellQuote(requiredCommandsSha)}`,
|
||||
"mkdir -p \"$state_dir\"",
|
||||
"mkdir_exit=$?",
|
||||
"if [ \"$mkdir_exit\" -ne 0 ]; then",
|
||||
" MKDIR_EXIT=\"$mkdir_exit\" STATE_DIR=\"$state_dir\" node <<'NODE'",
|
||||
"console.log(JSON.stringify({ ok: false, status: 'submit-failed', stateDir: process.env.STATE_DIR, mkdirExitCode: Number(process.env.MKDIR_EXIT || '1'), valuesPrinted: false }));",
|
||||
"NODE",
|
||||
" exit \"$mkdir_exit\"",
|
||||
"fi",
|
||||
"job_id=\"$(date -u +%Y%m%dT%H%M%SZ)-$$\"",
|
||||
"status_file=\"$state_dir/install-status.json\"",
|
||||
"stdout_file=\"$state_dir/$job_id.stdout.log\"",
|
||||
"stderr_file=\"$state_dir/$job_id.stderr.log\"",
|
||||
"command_file=\"$state_dir/$job_id.install.sh\"",
|
||||
"commands_file=\"$state_dir/$job_id.commands.txt\"",
|
||||
"printf '%s' \"$install_command_b64\" | base64 -d >\"$command_file\"",
|
||||
"printf '%s' \"$check_commands_b64\" | base64 -d >\"$commands_file\"",
|
||||
"chmod 700 \"$command_file\"",
|
||||
"JOB_ID=\"$job_id\" STATE_DIR=\"$state_dir\" STATUS_FILE=\"$status_file\" STDOUT_FILE=\"$stdout_file\" STDERR_FILE=\"$stderr_file\" COMMAND_SHA=\"$command_sha\" REQUIRED_COMMANDS_SHA=\"$required_commands_sha\" node <<'NODE' >\"$status_file\"",
|
||||
"console.log(JSON.stringify({ ok: false, status: 'running', jobId: process.env.JOB_ID, stateDir: process.env.STATE_DIR, statusFile: process.env.STATUS_FILE, stdoutFile: process.env.STDOUT_FILE, stderrFile: process.env.STDERR_FILE, installCommandSha256: process.env.COMMAND_SHA, requiredCommandsSha256: process.env.REQUIRED_COMMANDS_SHA, startedAt: new Date().toISOString(), valuesPrinted: false }));",
|
||||
"NODE",
|
||||
"(",
|
||||
" set +e",
|
||||
" export HTTP_PROXY=\"$http_proxy_value\" HTTPS_PROXY=\"$https_proxy_value\" ALL_PROXY=\"$all_proxy_value\" NO_PROXY=\"$no_proxy_value\"",
|
||||
" export http_proxy=\"$http_proxy_value\" https_proxy=\"$https_proxy_value\" all_proxy=\"$all_proxy_value\" no_proxy=\"$no_proxy_value\"",
|
||||
" timeout \"$timeout_seconds\" /bin/bash \"$command_file\" >\"$stdout_file\" 2>\"$stderr_file\"",
|
||||
" install_exit=$?",
|
||||
" missing_commands=",
|
||||
" present_commands=",
|
||||
" while IFS= read -r command_name; do",
|
||||
" [ -z \"$command_name\" ] && continue",
|
||||
" if command -v \"$command_name\" >/dev/null 2>&1; then",
|
||||
" present_commands=\"$present_commands${present_commands:+,}$command_name\"",
|
||||
" else",
|
||||
" missing_commands=\"$missing_commands${missing_commands:+,}$command_name\"",
|
||||
" fi",
|
||||
" done <\"$commands_file\"",
|
||||
" stdout_tail=$(tail -c 4000 \"$stdout_file\" 2>/dev/null | sed 's/\"/\\\\\"/g' | tr '\\n' ' ' | cut -c1-4000)",
|
||||
" stderr_tail=$(tail -c 4000 \"$stderr_file\" 2>/dev/null | sed 's/\"/\\\\\"/g' | tr '\\n' ' ' | cut -c1-4000)",
|
||||
" JOB_ID=\"$job_id\" STATE_DIR=\"$state_dir\" STATUS_FILE=\"$status_file\" STDOUT_FILE=\"$stdout_file\" STDERR_FILE=\"$stderr_file\" COMMAND_SHA=\"$command_sha\" REQUIRED_COMMANDS_SHA=\"$required_commands_sha\" INSTALL_EXIT=\"$install_exit\" PRESENT_COMMANDS=\"$present_commands\" MISSING_COMMANDS=\"$missing_commands\" STDOUT_TAIL=\"$stdout_tail\" STDERR_TAIL=\"$stderr_tail\" node <<'NODE' >\"$status_file\"",
|
||||
"const missing = (process.env.MISSING_COMMANDS || '').split(',').filter(Boolean);",
|
||||
"const present = (process.env.PRESENT_COMMANDS || '').split(',').filter(Boolean);",
|
||||
"const exitCode = Number(process.env.INSTALL_EXIT || '1');",
|
||||
"const ok = exitCode === 0 && missing.length === 0;",
|
||||
"console.log(JSON.stringify({ ok, status: ok ? 'succeeded' : 'failed', jobId: process.env.JOB_ID, stateDir: process.env.STATE_DIR, statusFile: process.env.STATUS_FILE, stdoutFile: process.env.STDOUT_FILE, stderrFile: process.env.STDERR_FILE, installCommandSha256: process.env.COMMAND_SHA, requiredCommandsSha256: process.env.REQUIRED_COMMANDS_SHA, installExitCode: exitCode, presentCommands: present, missingCommands: missing, stdoutTail: process.env.STDOUT_TAIL || '', stderrTail: process.env.STDERR_TAIL || '', finishedAt: new Date().toISOString(), valuesPrinted: false }));",
|
||||
"NODE",
|
||||
") >/dev/null 2>&1 &",
|
||||
"pid=$!",
|
||||
"JOB_ID=\"$job_id\" PID=\"$pid\" STATE_DIR=\"$state_dir\" STATUS_FILE=\"$status_file\" STDOUT_FILE=\"$stdout_file\" STDERR_FILE=\"$stderr_file\" COMMAND_SHA=\"$command_sha\" REQUIRED_COMMANDS_SHA=\"$required_commands_sha\" node <<'NODE'",
|
||||
"console.log(JSON.stringify({ ok: true, status: 'submitted', jobId: process.env.JOB_ID, pid: Number(process.env.PID || '0'), stateDir: process.env.STATE_DIR, statusFile: process.env.STATUS_FILE, stdoutFile: process.env.STDOUT_FILE, stderrFile: process.env.STDERR_FILE, installCommandSha256: process.env.COMMAND_SHA, requiredCommandsSha256: process.env.REQUIRED_COMMANDS_SHA, valuesPrinted: false }));",
|
||||
"NODE",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sourceWorkspaceStatusScript(spec: HwlabRuntimeLaneSpec, sourceWorkspace: HwlabRuntimeSourceWorkspaceSpec): string {
|
||||
const requiredCommands = sourceWorkspace.requiredCommands.join("\n");
|
||||
const requiredFiles = sourceWorkspace.requiredFiles.join("\n");
|
||||
|
||||
Reference in New Issue
Block a user