|
|
|
@@ -0,0 +1,628 @@
|
|
|
|
|
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
|
|
import { dirname } from "node:path";
|
|
|
|
|
import type { UniDeskConfig } from "./config";
|
|
|
|
|
import { rootPath } from "./config";
|
|
|
|
|
import type { RenderedCliResult } from "./output";
|
|
|
|
|
import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy";
|
|
|
|
|
import {
|
|
|
|
|
asRecord,
|
|
|
|
|
booleanField,
|
|
|
|
|
integerField,
|
|
|
|
|
parseOpsApplyOptions,
|
|
|
|
|
parseOpsCommonOptions,
|
|
|
|
|
readYamlRecord,
|
|
|
|
|
recordField,
|
|
|
|
|
stringField,
|
|
|
|
|
} from "./platform-infra-ops-library";
|
|
|
|
|
|
|
|
|
|
const configPath = rootPath("config", "platform-infra", "webterm.yaml");
|
|
|
|
|
const serviceName = "webterm";
|
|
|
|
|
|
|
|
|
|
interface WebtermConfig {
|
|
|
|
|
defaults: { targetId: string };
|
|
|
|
|
targets: WebtermTarget[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WebtermTarget {
|
|
|
|
|
id: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
runtime: {
|
|
|
|
|
mode: "host-docker";
|
|
|
|
|
workDir: string;
|
|
|
|
|
composePath: string;
|
|
|
|
|
composeProject: string;
|
|
|
|
|
serviceName: string;
|
|
|
|
|
containerName: string;
|
|
|
|
|
image: string;
|
|
|
|
|
envFile: string;
|
|
|
|
|
hostPort: number;
|
|
|
|
|
containerPort: number;
|
|
|
|
|
privileged: boolean;
|
|
|
|
|
pid: "host" | "container";
|
|
|
|
|
sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>;
|
|
|
|
|
environment: Record<string, string>;
|
|
|
|
|
};
|
|
|
|
|
publicExposure: {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
publicBaseUrl: string;
|
|
|
|
|
dns: { hostname: string; expectedA: string; resolvers: string[] };
|
|
|
|
|
upstream: { host: string; port: number };
|
|
|
|
|
pk01: {
|
|
|
|
|
route: string;
|
|
|
|
|
caddyConfigPath: string;
|
|
|
|
|
caddyServiceName: string;
|
|
|
|
|
responseHeaderTimeoutSeconds: number;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function runWebtermCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
|
|
|
const [action = "plan", ...rest] = args;
|
|
|
|
|
if (action === "plan") {
|
|
|
|
|
const result = plan(parseCommon(rest).targetId);
|
|
|
|
|
return wantsFull(rest) ? result : renderPlan(result);
|
|
|
|
|
}
|
|
|
|
|
if (action === "apply") {
|
|
|
|
|
const options = parseOpsApplyOptions(rest);
|
|
|
|
|
const result = await apply(config, options.targetId, options.confirm);
|
|
|
|
|
return options.full || options.raw ? result : renderApply(result);
|
|
|
|
|
}
|
|
|
|
|
if (action === "status") {
|
|
|
|
|
const options = parseCommon(rest);
|
|
|
|
|
const result = status(options.targetId);
|
|
|
|
|
return options.full || options.raw ? result : renderStatus(result);
|
|
|
|
|
}
|
|
|
|
|
if (action === "validate") {
|
|
|
|
|
const options = parseCommon(rest);
|
|
|
|
|
const result = validate(options.targetId);
|
|
|
|
|
return options.full || options.raw ? result : renderValidate(result);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
error: "unsupported-webterm-command",
|
|
|
|
|
args,
|
|
|
|
|
help: webtermHelp(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function webtermHelp(): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
command: "platform-infra webterm plan|apply|status|validate",
|
|
|
|
|
examples: [
|
|
|
|
|
"bun scripts/cli.ts platform-infra webterm plan",
|
|
|
|
|
"bun scripts/cli.ts platform-infra webterm apply --dry-run",
|
|
|
|
|
"bun scripts/cli.ts platform-infra webterm apply --confirm",
|
|
|
|
|
"bun scripts/cli.ts platform-infra webterm status",
|
|
|
|
|
"bun scripts/cli.ts platform-infra webterm validate",
|
|
|
|
|
],
|
|
|
|
|
configPath,
|
|
|
|
|
description: "Deploy the host-Docker Web Terminal from YAML and expose it through PK01 Caddy HTTPS without touching the existing 7682 service.",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function plan(targetId: string | null): Record<string, unknown> {
|
|
|
|
|
const target = resolveTarget(targetId);
|
|
|
|
|
const compose = renderCompose(target);
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
service: serviceName,
|
|
|
|
|
configPath,
|
|
|
|
|
target: targetSummary(target),
|
|
|
|
|
compose: {
|
|
|
|
|
path: target.runtime.composePath,
|
|
|
|
|
project: target.runtime.composeProject,
|
|
|
|
|
service: target.runtime.serviceName,
|
|
|
|
|
container: target.runtime.containerName,
|
|
|
|
|
fingerprint: fingerprint(compose),
|
|
|
|
|
hostPort: target.runtime.hostPort,
|
|
|
|
|
containerPort: target.runtime.containerPort,
|
|
|
|
|
},
|
|
|
|
|
publicExposure: exposureSummary(target),
|
|
|
|
|
policy: policyChecks(target, compose),
|
|
|
|
|
next: {
|
|
|
|
|
dryRun: "bun scripts/cli.ts platform-infra webterm apply --dry-run",
|
|
|
|
|
apply: "bun scripts/cli.ts platform-infra webterm apply --confirm",
|
|
|
|
|
status: "bun scripts/cli.ts platform-infra webterm status",
|
|
|
|
|
validate: "bun scripts/cli.ts platform-infra webterm validate",
|
|
|
|
|
},
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function apply(config: UniDeskConfig, targetId: string | null, confirm: boolean): Promise<Record<string, unknown>> {
|
|
|
|
|
const target = resolveTarget(targetId);
|
|
|
|
|
const compose = renderCompose(target);
|
|
|
|
|
const policy = policyChecks(target, compose);
|
|
|
|
|
const failedPolicy = policy.filter((item) => item.ok === false);
|
|
|
|
|
if (!confirm) {
|
|
|
|
|
return {
|
|
|
|
|
ok: failedPolicy.length === 0,
|
|
|
|
|
mode: "dry-run",
|
|
|
|
|
target: targetSummary(target),
|
|
|
|
|
compose: { path: target.runtime.composePath, fingerprint: fingerprint(compose), preview: compose },
|
|
|
|
|
publicExposure: exposureSummary(target),
|
|
|
|
|
policy,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (failedPolicy.length > 0) {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
mode: "policy-blocked",
|
|
|
|
|
target: targetSummary(target),
|
|
|
|
|
policy,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mkdirSync(dirname(target.runtime.composePath), { recursive: true });
|
|
|
|
|
writeFileSync(target.runtime.composePath, compose, { encoding: "utf8", mode: 0o644 });
|
|
|
|
|
const composeResult = spawnSync("docker", ["compose", "-f", target.runtime.composePath, "-p", target.runtime.composeProject, "up", "-d", "--force-recreate"], {
|
|
|
|
|
cwd: target.runtime.workDir,
|
|
|
|
|
encoding: "utf8",
|
|
|
|
|
});
|
|
|
|
|
const localProbe = localHttpProbeWithRetry(target.runtime.hostPort, "/login", 10, 500);
|
|
|
|
|
const caddy = target.publicExposure.enabled
|
|
|
|
|
? await applyPk01CaddySiteBlock(
|
|
|
|
|
config,
|
|
|
|
|
serviceName,
|
|
|
|
|
caddyExposure(target),
|
|
|
|
|
renderWebtermCaddySiteBlock({
|
|
|
|
|
hostname: target.publicExposure.dns.hostname,
|
|
|
|
|
upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`,
|
|
|
|
|
responseHeaderTimeoutSeconds: target.publicExposure.pk01.responseHeaderTimeoutSeconds,
|
|
|
|
|
}),
|
|
|
|
|
caddyManagedBlockMarkers(serviceName),
|
|
|
|
|
)
|
|
|
|
|
: { ok: true, action: "not-enabled" };
|
|
|
|
|
const publicProbe = target.publicExposure.enabled ? publicHttpProbeWithRetry(target.publicExposure.publicBaseUrl, "/login", 6, 1000) : { ok: true, skipped: true };
|
|
|
|
|
return {
|
|
|
|
|
ok: composeResult.status === 0 && localProbe.ok === true && caddy.ok !== false && publicProbe.ok === true,
|
|
|
|
|
mode: "confirmed",
|
|
|
|
|
target: targetSummary(target),
|
|
|
|
|
compose: {
|
|
|
|
|
path: target.runtime.composePath,
|
|
|
|
|
project: target.runtime.composeProject,
|
|
|
|
|
fingerprint: fingerprint(compose),
|
|
|
|
|
up: commandSummary(composeResult),
|
|
|
|
|
},
|
|
|
|
|
localProbe,
|
|
|
|
|
pk01Caddy: caddy,
|
|
|
|
|
publicProbe,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function status(targetId: string | null): Record<string, unknown> {
|
|
|
|
|
const target = resolveTarget(targetId);
|
|
|
|
|
const inspect = spawnSync("docker", ["inspect", target.runtime.containerName], { encoding: "utf8" });
|
|
|
|
|
const ps = spawnSync("docker", ["ps", "--filter", `name=^/${target.runtime.containerName}$`, "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], { encoding: "utf8" });
|
|
|
|
|
const localProbe = localHttpProbe(target.runtime.hostPort, "/login");
|
|
|
|
|
const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true };
|
|
|
|
|
return {
|
|
|
|
|
ok: ps.status === 0 && ps.stdout.trim().length > 0 && localProbe.ok === true && publicProbe.ok === true,
|
|
|
|
|
target: targetSummary(target),
|
|
|
|
|
compose: {
|
|
|
|
|
path: target.runtime.composePath,
|
|
|
|
|
exists: existsSync(target.runtime.composePath),
|
|
|
|
|
fingerprint: existsSync(target.runtime.composePath) ? fingerprint(readFileSync(target.runtime.composePath, "utf8")) : null,
|
|
|
|
|
},
|
|
|
|
|
container: {
|
|
|
|
|
exists: inspect.status === 0,
|
|
|
|
|
ps: ps.stdout.trim(),
|
|
|
|
|
},
|
|
|
|
|
localProbe,
|
|
|
|
|
publicProbe,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validate(targetId: string | null): Record<string, unknown> {
|
|
|
|
|
const target = resolveTarget(targetId);
|
|
|
|
|
const dns = dnsProbe(target.publicExposure.dns.hostname);
|
|
|
|
|
const localProbe = localHttpProbe(target.runtime.hostPort, "/login");
|
|
|
|
|
const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true };
|
|
|
|
|
return {
|
|
|
|
|
ok: localProbe.ok === true && publicProbe.ok === true && dns.addresses.includes(target.publicExposure.dns.expectedA),
|
|
|
|
|
target: targetSummary(target),
|
|
|
|
|
dns,
|
|
|
|
|
localProbe,
|
|
|
|
|
publicProbe,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readWebtermConfig(): WebtermConfig {
|
|
|
|
|
const yaml = readYamlRecord(configPath, "platform-infra-webterm");
|
|
|
|
|
const defaults = recordField(yaml, "defaults", "webterm");
|
|
|
|
|
const targetsRaw = yaml.targets;
|
|
|
|
|
if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array");
|
|
|
|
|
return {
|
|
|
|
|
defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") },
|
|
|
|
|
targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseTarget(record: Record<string, unknown>, path: string): WebtermTarget {
|
|
|
|
|
const runtime = recordField(record, "runtime", path);
|
|
|
|
|
const exposure = recordField(record, "publicExposure", path);
|
|
|
|
|
const dns = recordField(exposure, "dns", `${path}.publicExposure`);
|
|
|
|
|
const upstream = recordField(exposure, "upstream", `${path}.publicExposure`);
|
|
|
|
|
const pk01 = recordField(exposure, "pk01", `${path}.publicExposure`);
|
|
|
|
|
const environment = parseEnvironment(recordField(runtime, "environment", `${path}.runtime`), `${path}.runtime.environment`);
|
|
|
|
|
const sourceMounts = parseSourceMounts(runtime.sourceMounts, `${path}.runtime.sourceMounts`);
|
|
|
|
|
const target: WebtermTarget = {
|
|
|
|
|
id: stringField(record, "id", path),
|
|
|
|
|
enabled: booleanField(record, "enabled", path),
|
|
|
|
|
runtime: {
|
|
|
|
|
mode: parseLiteral(stringField(runtime, "mode", `${path}.runtime`), "host-docker", `${path}.runtime.mode`),
|
|
|
|
|
workDir: stringField(runtime, "workDir", `${path}.runtime`),
|
|
|
|
|
composePath: stringField(runtime, "composePath", `${path}.runtime`),
|
|
|
|
|
composeProject: stringField(runtime, "composeProject", `${path}.runtime`),
|
|
|
|
|
serviceName: stringField(runtime, "serviceName", `${path}.runtime`),
|
|
|
|
|
containerName: stringField(runtime, "containerName", `${path}.runtime`),
|
|
|
|
|
image: stringField(runtime, "image", `${path}.runtime`),
|
|
|
|
|
envFile: stringField(runtime, "envFile", `${path}.runtime`),
|
|
|
|
|
hostPort: portField(runtime, "hostPort", `${path}.runtime`),
|
|
|
|
|
containerPort: portField(runtime, "containerPort", `${path}.runtime`),
|
|
|
|
|
privileged: booleanField(runtime, "privileged", `${path}.runtime`),
|
|
|
|
|
pid: parsePid(stringField(runtime, "pid", `${path}.runtime`), `${path}.runtime.pid`),
|
|
|
|
|
sourceMounts,
|
|
|
|
|
environment,
|
|
|
|
|
},
|
|
|
|
|
publicExposure: {
|
|
|
|
|
enabled: booleanField(exposure, "enabled", `${path}.publicExposure`),
|
|
|
|
|
publicBaseUrl: stringField(exposure, "publicBaseUrl", `${path}.publicExposure`),
|
|
|
|
|
dns: {
|
|
|
|
|
hostname: stringField(dns, "hostname", `${path}.publicExposure.dns`),
|
|
|
|
|
expectedA: stringField(dns, "expectedA", `${path}.publicExposure.dns`),
|
|
|
|
|
resolvers: stringArray(dns.resolvers, `${path}.publicExposure.dns.resolvers`),
|
|
|
|
|
},
|
|
|
|
|
upstream: {
|
|
|
|
|
host: stringField(upstream, "host", `${path}.publicExposure.upstream`),
|
|
|
|
|
port: portField(upstream, "port", `${path}.publicExposure.upstream`),
|
|
|
|
|
},
|
|
|
|
|
pk01: {
|
|
|
|
|
route: stringField(pk01, "route", `${path}.publicExposure.pk01`),
|
|
|
|
|
caddyConfigPath: stringField(pk01, "caddyConfigPath", `${path}.publicExposure.pk01`),
|
|
|
|
|
caddyServiceName: stringField(pk01, "caddyServiceName", `${path}.publicExposure.pk01`),
|
|
|
|
|
responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
if (target.runtime.hostPort === 7682) throw new Error(`${path}.runtime.hostPort must not reuse existing 7682`);
|
|
|
|
|
if (target.runtime.containerName === "web-terminal-7682") throw new Error(`${path}.runtime.containerName must not reuse web-terminal-7682`);
|
|
|
|
|
if (!target.publicExposure.publicBaseUrl.startsWith("https://")) throw new Error(`${path}.publicExposure.publicBaseUrl must be https`);
|
|
|
|
|
return target;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveTarget(targetId: string | null): WebtermTarget {
|
|
|
|
|
const config = readWebtermConfig();
|
|
|
|
|
const id = targetId ?? config.defaults.targetId;
|
|
|
|
|
const target = config.targets.find((item) => item.id === id);
|
|
|
|
|
if (target === undefined) throw new Error(`unknown webterm target: ${id}`);
|
|
|
|
|
return target;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderCompose(target: WebtermTarget): string {
|
|
|
|
|
const envLines = Object.entries(target.runtime.environment)
|
|
|
|
|
.map(([key, value]) => ` ${key}: ${quoteYaml(value)}`)
|
|
|
|
|
.join("\n");
|
|
|
|
|
const volumeLines = target.runtime.sourceMounts
|
|
|
|
|
.map((mount) => ` - ${quoteYaml(`${mount.source}:${mount.target}${mount.readOnly ? ":ro" : ""}`)}`)
|
|
|
|
|
.join("\n");
|
|
|
|
|
return `name: ${target.runtime.composeProject}
|
|
|
|
|
|
|
|
|
|
services:
|
|
|
|
|
${target.runtime.serviceName}:
|
|
|
|
|
image: ${target.runtime.image}
|
|
|
|
|
container_name: ${target.runtime.containerName}
|
|
|
|
|
restart: unless-stopped
|
|
|
|
|
ports:
|
|
|
|
|
- "${target.runtime.hostPort}:${target.runtime.containerPort}"
|
|
|
|
|
privileged: ${target.runtime.privileged ? "true" : "false"}
|
|
|
|
|
pid: ${target.runtime.pid}
|
|
|
|
|
env_file:
|
|
|
|
|
- ${target.runtime.envFile}
|
|
|
|
|
volumes:
|
|
|
|
|
${volumeLines}
|
|
|
|
|
environment:
|
|
|
|
|
${envLines}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function policyChecks(target: WebtermTarget, compose: string): Array<Record<string, unknown> & { ok: boolean }> {
|
|
|
|
|
return [
|
|
|
|
|
{ name: "enabled", ok: target.enabled, detail: "target must be enabled" },
|
|
|
|
|
{ name: "no-7682-host-port", ok: target.runtime.hostPort !== 7682, detail: "existing 7682 service must not be reused" },
|
|
|
|
|
{ name: "no-7682-container", ok: target.runtime.containerName !== "web-terminal-7682", detail: "existing 7682 container must not be reused" },
|
|
|
|
|
{ name: "compose-path-7683", ok: target.runtime.composePath.includes("7683"), detail: target.runtime.composePath },
|
|
|
|
|
{ name: "public-https", ok: target.publicExposure.publicBaseUrl.startsWith("https://"), detail: target.publicExposure.publicBaseUrl },
|
|
|
|
|
{ name: "compose-renders-7683", ok: compose.includes(`"${target.runtime.hostPort}:${target.runtime.containerPort}"`), detail: `${target.runtime.hostPort}:${target.runtime.containerPort}` },
|
|
|
|
|
{ name: "source-mounts-present", ok: target.runtime.sourceMounts.length >= 3, detail: `mounts=${target.runtime.sourceMounts.length}` },
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function caddyExposure(target: WebtermTarget): Parameters<typeof applyPk01CaddySiteBlock>[2] {
|
|
|
|
|
return {
|
|
|
|
|
enabled: target.publicExposure.enabled,
|
|
|
|
|
dns: { hostname: target.publicExposure.dns.hostname },
|
|
|
|
|
frpc: { remotePort: target.publicExposure.upstream.port },
|
|
|
|
|
pk01: target.publicExposure.pk01,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderWebtermCaddySiteBlock(params: {
|
|
|
|
|
hostname: string;
|
|
|
|
|
upstream: string;
|
|
|
|
|
responseHeaderTimeoutSeconds: number;
|
|
|
|
|
}): string {
|
|
|
|
|
return `${params.hostname} {
|
|
|
|
|
reverse_proxy ${params.upstream} {
|
|
|
|
|
flush_interval -1
|
|
|
|
|
stream_close_delay 5m
|
|
|
|
|
transport http {
|
|
|
|
|
response_header_timeout ${params.responseHeaderTimeoutSeconds}s
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function targetSummary(target: WebtermTarget): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
id: target.id,
|
|
|
|
|
enabled: target.enabled,
|
|
|
|
|
mode: target.runtime.mode,
|
|
|
|
|
workDir: target.runtime.workDir,
|
|
|
|
|
composeProject: target.runtime.composeProject,
|
|
|
|
|
containerName: target.runtime.containerName,
|
|
|
|
|
image: target.runtime.image,
|
|
|
|
|
sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`),
|
|
|
|
|
hostPort: target.runtime.hostPort,
|
|
|
|
|
containerPort: target.runtime.containerPort,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function exposureSummary(target: WebtermTarget): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
enabled: target.publicExposure.enabled,
|
|
|
|
|
publicBaseUrl: target.publicExposure.publicBaseUrl,
|
|
|
|
|
hostname: target.publicExposure.dns.hostname,
|
|
|
|
|
expectedA: target.publicExposure.dns.expectedA,
|
|
|
|
|
upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`,
|
|
|
|
|
pk01Route: target.publicExposure.pk01.route,
|
|
|
|
|
marker: caddyManagedBlockMarkers(serviceName).start,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function localHttpProbe(port: number, path: string): Record<string, unknown> {
|
|
|
|
|
return httpProbe(`http://127.0.0.1:${port}`, path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publicHttpProbe(baseUrl: string, path: string): Record<string, unknown> {
|
|
|
|
|
return httpProbe(baseUrl, path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function localHttpProbeWithRetry(port: number, path: string, attempts: number, delayMs: number): Record<string, unknown> {
|
|
|
|
|
return httpProbeWithRetry(() => localHttpProbe(port, path), attempts, delayMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publicHttpProbeWithRetry(baseUrl: string, path: string, attempts: number, delayMs: number): Record<string, unknown> {
|
|
|
|
|
return httpProbeWithRetry(() => publicHttpProbe(baseUrl, path), attempts, delayMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function httpProbeWithRetry(run: () => Record<string, unknown>, attempts: number, delayMs: number): Record<string, unknown> {
|
|
|
|
|
let latest = run();
|
|
|
|
|
for (let attempt = 1; attempt < attempts && latest.ok !== true; attempt += 1) {
|
|
|
|
|
spawnSync("sleep", [(delayMs / 1000).toFixed(3)]);
|
|
|
|
|
latest = run();
|
|
|
|
|
}
|
|
|
|
|
return { ...latest, attempts };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function httpProbe(baseUrl: string, path: string): Record<string, unknown> {
|
|
|
|
|
const url = `${baseUrl.replace(/\/+$/u, "")}${path}`;
|
|
|
|
|
const result = spawnSync("curl", ["-fsS", "-L", "--connect-timeout", "5", "--max-time", "15", "-o", "/dev/null", "-w", "%{http_code}", url], { encoding: "utf8" });
|
|
|
|
|
const status = Number(result.stdout.trim());
|
|
|
|
|
return {
|
|
|
|
|
ok: result.status === 0 && Number.isInteger(status) && status >= 200 && status < 500,
|
|
|
|
|
url,
|
|
|
|
|
status: Number.isInteger(status) ? status : null,
|
|
|
|
|
exitCode: result.status,
|
|
|
|
|
stderrTail: result.status === 0 ? "" : result.stderr.slice(-1000),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dnsProbe(hostname: string): Record<string, unknown> & { addresses: string[] } {
|
|
|
|
|
const result = spawnSync("getent", ["hosts", hostname], { encoding: "utf8" });
|
|
|
|
|
const addresses = result.stdout
|
|
|
|
|
.split(/\r?\n/u)
|
|
|
|
|
.map((line) => line.trim().split(/\s+/u)[0] ?? "")
|
|
|
|
|
.filter((value) => /^\d+\.\d+\.\d+\.\d+$/u.test(value));
|
|
|
|
|
return { ok: result.status === 0 && addresses.length > 0, hostname, addresses, exitCode: result.status };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commandSummary(result: SpawnSyncReturns<string>): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
exitCode: result.status,
|
|
|
|
|
stdoutTail: result.status === 0 ? result.stdout.slice(-2000) : result.stdout.slice(-4000),
|
|
|
|
|
stderrTail: result.status === 0 ? "" : result.stderr.slice(-4000),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseCommon(args: string[]): ReturnType<typeof parseOpsCommonOptions> {
|
|
|
|
|
return parseOpsCommonOptions(args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wantsFull(args: string[]): boolean {
|
|
|
|
|
return args.includes("--full") || args.includes("--raw");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseEnvironment(record: Record<string, unknown>, path: string): Record<string, string> {
|
|
|
|
|
const result: Record<string, string> = {};
|
|
|
|
|
for (const [key, value] of Object.entries(record)) {
|
|
|
|
|
if (!/^[A-Z_][A-Z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} must be an env key`);
|
|
|
|
|
if (typeof value !== "string") throw new Error(`${path}.${key} must be a string`);
|
|
|
|
|
result[key] = value;
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseSourceMounts(value: unknown, path: string): WebtermTarget["runtime"]["sourceMounts"] {
|
|
|
|
|
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
|
|
|
|
|
return value.map((item, index) => {
|
|
|
|
|
const record = asRecord(item, `${path}[${index}]`);
|
|
|
|
|
return {
|
|
|
|
|
source: stringField(record, "source", `${path}[${index}]`),
|
|
|
|
|
target: stringField(record, "target", `${path}[${index}]`),
|
|
|
|
|
readOnly: booleanField(record, "readOnly", `${path}[${index}]`),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseLiteral<T extends string>(value: string, expected: T, path: string): T {
|
|
|
|
|
if (value !== expected) throw new Error(`${path} must be ${expected}`);
|
|
|
|
|
return expected;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parsePid(value: string, path: string): "host" | "container" {
|
|
|
|
|
if (value !== "host" && value !== "container") throw new Error(`${path} must be host or container`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function portField(record: Record<string, unknown>, key: string, path: string): number {
|
|
|
|
|
const value = integerField(record, key, path);
|
|
|
|
|
if (value < 1 || value > 65535) throw new Error(`${path}.${key} must be a TCP port`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringArray(value: unknown, path: string): string[] {
|
|
|
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of strings`);
|
|
|
|
|
return value as string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function quoteYaml(value: string): string {
|
|
|
|
|
return JSON.stringify(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fingerprint(value: string): string {
|
|
|
|
|
const result = spawnSync("sha256sum", { input: value, encoding: "utf8" });
|
|
|
|
|
return `sha256:${(result.stdout.split(/\s+/u)[0] ?? "").trim()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
|
|
|
|
|
const target = rec(result.target);
|
|
|
|
|
const exposure = rec(result.publicExposure);
|
|
|
|
|
const failed = arr(result.policy).filter((item) => item.ok === false);
|
|
|
|
|
const lines = [
|
|
|
|
|
"PLATFORM-INFRA WEBTERM PLAN",
|
|
|
|
|
...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [
|
|
|
|
|
["TARGET", str(target.id), "mode", str(target.mode)],
|
|
|
|
|
["PORT", str(target.hostPort), "container", str(target.containerName)],
|
|
|
|
|
["IMAGE", str(target.image), "compose", str(rec(result.compose).path)],
|
|
|
|
|
["PUBLIC", bool(exposure.enabled), "url", str(exposure.publicBaseUrl)],
|
|
|
|
|
["UPSTREAM", str(exposure.upstream), "pk01", str(exposure.pk01Route)],
|
|
|
|
|
["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"],
|
|
|
|
|
]),
|
|
|
|
|
"",
|
|
|
|
|
"NEXT",
|
|
|
|
|
" dry-run: bun scripts/cli.ts platform-infra webterm apply --dry-run",
|
|
|
|
|
" apply: bun scripts/cli.ts platform-infra webterm apply --confirm",
|
|
|
|
|
" status: bun scripts/cli.ts platform-infra webterm status",
|
|
|
|
|
];
|
|
|
|
|
return rendered(result, "platform-infra webterm plan", lines);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderApply(result: Record<string, unknown>): RenderedCliResult {
|
|
|
|
|
const target = rec(result.target);
|
|
|
|
|
const compose = rec(result.compose);
|
|
|
|
|
const localProbe = rec(result.localProbe);
|
|
|
|
|
const publicProbe = rec(result.publicProbe);
|
|
|
|
|
const caddy = rec(result.pk01Caddy);
|
|
|
|
|
const lines = [
|
|
|
|
|
"PLATFORM-INFRA WEBTERM APPLY",
|
|
|
|
|
...table(["TARGET", "PORT", "MODE", "STATUS"], [[str(target.id), str(target.hostPort), str(result.mode), result.ok === false ? "failed" : "ok"]]),
|
|
|
|
|
"",
|
|
|
|
|
"COMPOSE",
|
|
|
|
|
...table(["PATH", "PROJECT", "UP"], [[str(compose.path), str(target.composeProject), str(rec(compose.up).exitCode, "-")]]),
|
|
|
|
|
"",
|
|
|
|
|
"CHECKS",
|
|
|
|
|
...table(["CHECK", "OK", "DETAIL"], [
|
|
|
|
|
["local", bool(localProbe.ok), str(localProbe.url, "-")],
|
|
|
|
|
["pk01Caddy", bool(caddy.ok), str(caddy.action, str(caddy.hostname, "-"))],
|
|
|
|
|
["public", bool(publicProbe.ok), str(publicProbe.url, "-")],
|
|
|
|
|
]),
|
|
|
|
|
"",
|
|
|
|
|
"NEXT",
|
|
|
|
|
" status: bun scripts/cli.ts platform-infra webterm status",
|
|
|
|
|
" validate: bun scripts/cli.ts platform-infra webterm validate",
|
|
|
|
|
];
|
|
|
|
|
return rendered(result, "platform-infra webterm apply", lines);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderStatus(result: Record<string, unknown>): RenderedCliResult {
|
|
|
|
|
const target = rec(result.target);
|
|
|
|
|
const container = rec(result.container);
|
|
|
|
|
const localProbe = rec(result.localProbe);
|
|
|
|
|
const publicProbe = rec(result.publicProbe);
|
|
|
|
|
const lines = [
|
|
|
|
|
"PLATFORM-INFRA WEBTERM STATUS",
|
|
|
|
|
...table(["TARGET", "PORT", "CONTAINER", "STATUS"], [[str(target.id), str(target.hostPort), str(target.containerName), result.ok === false ? "failed" : "ok"]]),
|
|
|
|
|
"",
|
|
|
|
|
"CONTAINER",
|
|
|
|
|
str(container.ps, "-"),
|
|
|
|
|
"",
|
|
|
|
|
"CHECKS",
|
|
|
|
|
...table(["CHECK", "OK", "DETAIL"], [
|
|
|
|
|
["local", bool(localProbe.ok), str(localProbe.url, "-")],
|
|
|
|
|
["public", bool(publicProbe.ok), str(publicProbe.url, "-")],
|
|
|
|
|
]),
|
|
|
|
|
];
|
|
|
|
|
return rendered(result, "platform-infra webterm status", lines);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderValidate(result: Record<string, unknown>): RenderedCliResult {
|
|
|
|
|
const dns = rec(result.dns);
|
|
|
|
|
const localProbe = rec(result.localProbe);
|
|
|
|
|
const publicProbe = rec(result.publicProbe);
|
|
|
|
|
const lines = [
|
|
|
|
|
"PLATFORM-INFRA WEBTERM VALIDATE",
|
|
|
|
|
...table(["CHECK", "OK", "DETAIL"], [
|
|
|
|
|
["dns", bool(dns.ok), values(dns.addresses).map(String).join(",") || "-"],
|
|
|
|
|
["local", bool(localProbe.ok), str(localProbe.url, "-")],
|
|
|
|
|
["public", bool(publicProbe.ok), str(publicProbe.url, "-")],
|
|
|
|
|
]),
|
|
|
|
|
];
|
|
|
|
|
return rendered(result, "platform-infra webterm validate", lines);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rendered(result: Record<string, unknown>, command: string, lines: string[]): RenderedCliResult {
|
|
|
|
|
return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rec(value: unknown): Record<string, unknown> {
|
|
|
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function arr(value: unknown): Record<string, unknown>[] {
|
|
|
|
|
return Array.isArray(value) ? value.map(rec) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function values(value: unknown): unknown[] {
|
|
|
|
|
return Array.isArray(value) ? value : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function str(value: unknown, fallback = "-"): string {
|
|
|
|
|
return value === undefined || value === null || value === "" ? fallback : String(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bool(value: unknown): string {
|
|
|
|
|
return value === true ? "yes" : value === false ? "no" : "-";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function table(headers: string[], rows: string[][]): string[] {
|
|
|
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)));
|
|
|
|
|
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
|
|
|
|
return [renderRow(headers), ...rows.map(renderRow)];
|
|
|
|
|
}
|