Files
pikasTech-unidesk/scripts/src/agentrun/trigger.ts
T
2026-07-01 11:03:18 +00:00

501 lines
20 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. trigger module for scripts/src/agentrun.ts.
// Moved mechanically from scripts/src/agentrun.ts:2747-3186 for #903.
// SPEC: PJ2026-01020108 cancel lifecycle + PJ2026-01020205 AipodSpec binding + PJ2026-01020302 session policy + PJ2026-01020305 cancel control + PJ2026-01060305/06 YAML execution policy and bounded output draft-2026-06-25-p0.
// Exposes AgentRun lane-scoped policy, AipodSpec SecretRef binding, cancel lifecycle, and bounded default output in the UniDesk CLI.
import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { rootPath, type UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { applyLocalCaddyManagedSite } from "../pk01-caddy";
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
import { runRemoteSshCommandCapture } from "../remote";
import { startJob } from "../jobs";
import {
AGENTRUN_CONFIG_PATH,
agentRunLaneSummary,
agentRunPipelineRunName,
agentRunProviderCredentialRefs,
resolveAgentRunLaneTarget,
type AgentRunCancelLifecycleSpec,
type AgentRunLaneSpec,
} from "../agentrun-lanes";
import {
agentRunImageArtifact,
placeholderAgentRunImage,
renderedFilesDigest,
renderedObjectsDigest,
renderAgentRunControlPlaneManifests,
renderAgentRunGitopsFiles,
type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import type { AgentRunPublicExposure } from "./config";
import type { ConfirmOptions, LaneConfirmOptions, SecretSyncOptions, TriggerOptions } from "./options";
import { triggerCurrentYamlLaneConfirmed } from "./cleanup-scripts";
import { readAgentRunClientConfig } from "./config";
import { displayValue } from "./options";
import { pathValue } from "./render";
import { startAsyncAgentRunJob } from "./rest-bridge";
import { collectLaneSecretSources, readSecretSourceValue, restartYamlLaneScript, secretSyncScript } from "./secrets";
import { capture, captureJsonPayload, compactCapture, isGitSha, stringOrNull } from "./utils";
import { yamlLaneK3sSourceStatusScript, yamlLaneSourceBootstrapProbeScript } from "./yaml-lane";
export function renderNextObjectLines(next: Record<string, unknown>): string[] {
return Object.values(next)
.filter((value): value is string => typeof value === "string" && value.length > 0)
.slice(0, 5)
.map((line) => ` ${line}`);
}
export function yesNo(value: unknown): string {
if (value === true) return "yes";
if (value === false) return "no";
if (value === null || value === undefined) return "-";
return displayValue(value);
}
export function shortSha(value: unknown): string {
const text = displayValue(value);
if (/^[0-9a-f]{40}$/u.test(text)) return text.slice(0, 12);
return text;
}
export function compactLaneSecretsStatus(secrets: Record<string, unknown>): Record<string, unknown> {
const items = Array.isArray(secrets.items) ? secrets.items.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
const missing = items
.filter((item) => item.present !== true || item.keyPresent !== true)
.map((item) => ({
namespace: item.namespace ?? null,
name: item.name ?? null,
key: item.key ?? null,
present: item.present ?? false,
keyPresent: item.keyPresent ?? false,
}));
return {
ready: secrets.ready ?? false,
count: secrets.count ?? items.length,
missingCount: missing.length,
missing,
valuesPrinted: false,
};
}
export function compactAgentRunLaneStatusTarget(spec: AgentRunLaneSpec): Record<string, unknown> {
return {
node: {
id: spec.nodeId,
route: spec.nodeRoute,
kubeRoute: spec.nodeKubeRoute,
},
lane: spec.lane,
version: spec.version,
source: {
repository: spec.source.repository,
branch: spec.source.branch,
},
runtime: {
namespace: spec.runtime.namespace,
managerDeployment: spec.runtime.managerDeployment,
managerService: spec.runtime.managerService,
},
ci: {
namespace: spec.ci.namespace,
pipeline: spec.ci.pipeline,
pipelineRunPrefix: spec.ci.pipelineRunPrefix,
},
gitops: {
branch: spec.gitops.branch,
argoApplication: spec.gitops.argoApplication,
},
gitMirror: {
namespace: spec.gitMirror.namespace,
readService: spec.gitMirror.readService,
writeService: spec.gitMirror.writeService,
repositoryCount: spec.gitMirror.repositories.length,
},
database: {
mode: spec.database.mode,
provider: spec.database.provider,
configRef: spec.database.configRef,
localPostgresExpectedAbsent: spec.database.localPostgresExpectedAbsent,
valuesPrinted: false,
},
secretCount: spec.secrets.length,
valuesPrinted: false,
};
}
export async function restartYamlLane(config: UniDeskConfig, options: LaneConfirmOptions): Promise<Record<string, unknown>> {
const { configPath, spec } = resolveAgentRunLaneTarget(options);
const plan = {
node: spec.nodeId,
kubeRoute: spec.nodeKubeRoute,
lane: spec.lane,
namespace: spec.runtime.namespace,
deployment: spec.runtime.managerDeployment,
annotation: "kubectl.kubernetes.io/restartedAt",
reason: "reload-lane-secrets-or-runtime-env",
valuesPrinted: false,
};
if (options.dryRun || !options.confirm) {
return {
ok: true,
command: "agentrun control-plane restart",
mode: "dry-run",
mutation: false,
configPath,
target: agentRunLaneSummary(spec),
plan,
next: {
confirm: `bun scripts/cli.ts agentrun control-plane restart --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
},
valuesPrinted: false,
};
}
const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", restartYamlLaneScript(spec)]);
const payload = captureJsonPayload(result);
return {
ok: result.exitCode === 0 && payload.ok !== false,
command: "agentrun control-plane restart",
mode: "confirmed-rollout-restart",
mutation: true,
configPath,
target: agentRunLaneSummary(spec),
plan,
result: payload,
capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 4000, stderrTailChars: 4000 }),
next: {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
},
valuesPrinted: false,
};
}
export async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Promise<Record<string, unknown>> {
const { configPath, spec } = resolveAgentRunLaneTarget(options);
const sources = collectLaneSecretSources(spec);
if (sources.length === 0) {
return {
ok: false,
command: "agentrun control-plane secret-sync",
mode: "yaml-declared-node-lane",
configPath,
target: agentRunLaneSummary(spec),
degradedReason: "lane-secret-sources-not-declared",
valuesPrinted: false,
};
}
const values = sources.map((source) => ({ spec: source, value: readSecretSourceValue(spec, source) }));
const plan = values.map(({ spec: item, value }) => ({
id: item.id,
namespace: item.targetRef.namespace,
secret: item.targetRef.name,
key: item.targetRef.key,
sourceRef: item.sourceRef,
sourceMode: item.sourceMode,
sourceKey: item.sourceKey,
sourcePath: value.redactedPath,
fingerprint: value.fingerprint,
valueBytes: value.valueBytes,
valuesPrinted: false,
}));
if (options.dryRun || !options.confirm) {
return {
ok: true,
command: "agentrun control-plane secret-sync",
mode: "dry-run",
mutation: false,
configPath,
target: agentRunLaneSummary(spec),
plan: { secretCount: plan.length, items: plan, valuesPrinted: false },
next: {
confirm: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
},
valuesPrinted: false,
};
}
const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", secretSyncScript(spec, values.map(({ spec: item, value }) => ({ targetRef: item.targetRef, value: value.value })))]);
const payload = captureJsonPayload(result);
return {
ok: result.exitCode === 0 && payload.ok !== false,
command: "agentrun control-plane secret-sync",
mode: "confirmed-sync",
mutation: true,
configPath,
target: agentRunLaneSummary(spec),
plan: { secretCount: plan.length, items: plan, valuesPrinted: false },
result: payload,
capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }),
next: {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
},
valuesPrinted: false,
};
}
export async function exposeAgentRun(_config: UniDeskConfig, options: ConfirmOptions): Promise<Record<string, unknown>> {
const clientConfig = readAgentRunClientConfig();
const exposure = clientConfig.publicExposure;
if (!exposure?.enabled) {
return {
ok: true,
command: "agentrun control-plane expose",
action: "disabled-by-yaml",
publicExposure: exposureSummary(exposure),
valuesPrinted: false,
};
}
if (options.dryRun) {
return {
ok: true,
command: "agentrun control-plane expose",
dryRun: true,
mutation: false,
publicExposure: exposureSummary(exposure),
plan: {
masterFrps: {
action: "ensure-allow-port",
configPath: exposure.masterFrps.configPath,
containerName: exposure.masterFrps.containerName,
remotePort: exposure.remotePort,
},
masterCaddy: {
action: "ensure-https-vhost",
domain: exposure.masterCaddy.domain,
configPath: exposure.masterCaddy.configPath,
serviceName: exposure.masterCaddy.serviceName,
upstreamBaseUrl: exposure.masterCaddy.upstreamBaseUrl,
responseHeaderTimeoutSeconds: exposure.masterCaddy.responseHeaderTimeoutSeconds,
},
},
next: { confirm: "bun scripts/cli.ts agentrun control-plane expose --confirm" },
valuesPrinted: false,
};
}
const masterFrps = applyAgentRunMasterFrpsAllowPort(exposure);
const masterCaddy = applyAgentRunMasterCaddySite(exposure);
const ok = masterFrps.ok === true && masterCaddy.ok === true;
return {
ok,
command: "agentrun control-plane expose",
mutation: true,
publicExposure: exposureSummary(exposure),
masterFrps,
masterCaddy,
validation: {
publicProbe: `curl -fsS ${exposure.publicBaseUrl.replace(/\/+$/u, "")}/health/readiness`,
directClient: "bun scripts/cli.ts agentrun get tasks --queue commander -o json",
valuesPrinted: false,
},
valuesPrinted: false,
};
}
export function exposureSummary(exposure: AgentRunPublicExposure | null | undefined): Record<string, unknown> {
if (!exposure) return { enabled: false, valuesPrinted: false };
return {
enabled: exposure.enabled,
proxyName: exposure.proxyName,
remotePort: exposure.remotePort,
publicBaseUrl: exposure.publicBaseUrl,
masterBaseUrl: exposure.masterBaseUrl,
frps: {
configPath: exposure.masterFrps.configPath,
containerName: exposure.masterFrps.containerName,
},
caddy: {
enabled: exposure.masterCaddy.enabled,
domain: exposure.masterCaddy.domain,
configPath: exposure.masterCaddy.configPath,
serviceName: exposure.masterCaddy.serviceName,
upstreamBaseUrl: exposure.masterCaddy.upstreamBaseUrl,
responseHeaderTimeoutSeconds: exposure.masterCaddy.responseHeaderTimeoutSeconds,
},
valuesPrinted: false,
};
}
export function applyAgentRunMasterFrpsAllowPort(exposure: AgentRunPublicExposure): Record<string, unknown> {
const pathValue = exposure.masterFrps.configPath;
const port = exposure.remotePort;
const container = exposure.masterFrps.containerName;
if (!existsSync(pathValue)) return { ok: false, error: "master-frps-config-missing", path: pathValue, valuesPrinted: false };
const before = readFileSync(pathValue, "utf8");
const alreadyAllowed = frpsAllowPortExists(before, port);
let backupPath: string | null = null;
let action = "kept-existing";
if (!alreadyAllowed) {
const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z");
backupPath = `${pathValue}.bak-agentrun-${stamp}`;
copyFileSync(pathValue, backupPath);
writeFileSync(pathValue, `${before.replace(/\s*$/u, "")}\n\n[[allowPorts]]\nstart = ${port}\nend = ${port}\n`, "utf8");
chmodSync(pathValue, statSync(backupPath).mode & 0o777);
action = "added-allow-port";
}
const restart = alreadyAllowed ? null : spawnSync("docker", ["restart", container], { encoding: "utf8" });
const inspect = spawnSync("docker", ["inspect", "-f", "{{.State.Running}}", container], { encoding: "utf8" });
const ok = alreadyAllowed || (restart?.status === 0 && inspect.status === 0 && String(inspect.stdout).trim() === "true");
return {
ok,
action,
path: pathValue,
backupPath,
remotePort: port,
containerName: container,
restart: restart === null ? null : { exitCode: restart.status, stdoutTail: String(restart.stdout).slice(-1000), stderrTail: String(restart.stderr).slice(-1000) },
inspect: { exitCode: inspect.status, running: String(inspect.stdout).trim() === "true", stderrTail: String(inspect.stderr).slice(-1000) },
valuesPrinted: false,
};
}
export function applyAgentRunMasterCaddySite(exposure: AgentRunPublicExposure): Record<string, unknown> {
const caddy = exposure.masterCaddy;
if (!caddy.enabled) return { ok: true, action: "disabled-by-yaml", valuesPrinted: false };
const pathValue = caddy.configPath;
if (!existsSync(pathValue)) return { ok: false, error: "master-caddy-config-missing", path: pathValue, valuesPrinted: false };
const desiredBlock = renderAgentRunCaddySiteBlock(caddy.domain, caddy.upstreamBaseUrl, caddy.responseHeaderTimeoutSeconds);
const applied = applyLocalCaddyManagedSite({
serviceId: "agentrun",
markerName: "agentrun",
configPath: pathValue,
serviceName: caddy.serviceName,
siteBlock: desiredBlock,
legacySiteDomain: caddy.domain,
});
return {
...applied,
domain: caddy.domain,
upstreamBaseUrl: caddy.upstreamBaseUrl,
responseHeaderTimeoutSeconds: caddy.responseHeaderTimeoutSeconds,
valuesPrinted: false,
};
}
export function renderAgentRunCaddySiteBlock(domain: string, upstreamBaseUrl: string, responseHeaderTimeoutSeconds: number): string {
const upstream = new URL(upstreamBaseUrl);
const upstreamHost = `${upstream.hostname}${upstream.port ? `:${upstream.port}` : ""}`;
return `${domain} {
encode zstd gzip
reverse_proxy ${upstreamHost} {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
dial_timeout 5s
response_header_timeout ${responseHeaderTimeoutSeconds}s
}
}
}`;
}
export function frpsAllowPortExists(toml: string, port: number): boolean {
const sections = toml.split(/(?=\[\[allowPorts\]\])/u);
return sections.some((section) => {
if (!section.includes("[[allowPorts]]")) return false;
const start = section.match(/^\s*start\s*=\s*(\d+)\s*$/mu);
const end = section.match(/^\s*end\s*=\s*(\d+)\s*$/mu);
return Number(start?.[1]) === port && Number(end?.[1]) === port;
});
}
export async function triggerCurrent(config: UniDeskConfig, options: TriggerOptions): Promise<Record<string, unknown>> {
return await triggerCurrentYamlLane(config, options, resolveAgentRunLaneTarget(options));
}
export async function triggerCurrentYamlLane(config: UniDeskConfig, options: TriggerOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
const spec = target.spec;
const probe = spec.source.statusMode === "k3s-git-mirror"
? await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)])
: await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapProbeScript(spec)]);
const source = captureJsonPayload(probe);
const sourceCommit = stringOrNull(source.sourceCommit);
const remoteBranchExists = source.remoteBranchExists === true;
const pipelineRun = sourceCommit !== null && isGitSha(sourceCommit) ? agentRunPipelineRunName(spec, sourceCommit) : null;
const placeholderImage = sourceCommit === null ? null : placeholderAgentRunImage(spec, sourceCommit);
const renderedFiles = placeholderImage === null ? [] : renderAgentRunGitopsFiles(spec, { sourceCommit, image: placeholderImage });
const sourcePlan = spec.source.statusMode === "k3s-git-mirror"
? {
statusMode: spec.source.statusMode,
sourceAuthority: spec.source.sourceAuthority,
sourceSnapshot: spec.source.sourceSnapshot,
repository: spec.source.repository,
branch: spec.source.branch,
remoteBranchExists,
remoteBranchCommit: stringOrNull(source.remoteBranchCommit),
sourceCommit,
sourceRef: stringOrNull(source.sourceRef),
sourceStageRef: stringOrNull(source.sourceStageRef),
snapshotPresent: source.snapshotPresent === true,
degradedReason: stringOrNull(source.degradedReason),
next: source.snapshotPresent === true ? null : `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm --wait`,
valuesPrinted: false,
}
: {
statusMode: spec.source.statusMode,
workspace: spec.source.workspace,
remote: spec.source.remote,
branch: spec.source.branch,
bootstrapFromBranch: spec.source.bootstrapFromBranch,
bootstrapTimeoutSeconds: spec.source.bootstrapTimeoutSeconds,
bootstrapPollSeconds: spec.source.bootstrapPollSeconds,
remoteBranchExists,
sourceCommit,
valuesPrinted: false,
};
const plan = {
node: spec.nodeId,
lane: spec.lane,
version: spec.version,
source: sourcePlan,
deploymentFormat: spec.deployment.format,
deploymentTruth: "config/agentrun.yaml",
removedServiceDeployJson: true,
pipelineRun,
imageBuild: {
repository: `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}`,
containerfile: spec.deployment.manager.imageBuild.containerfile,
buildArgNames: Object.keys(spec.deployment.manager.imageBuild.buildArgs).sort(),
timeoutSeconds: spec.deployment.manager.imageBuild.timeoutSeconds,
pollSeconds: spec.deployment.manager.imageBuild.pollSeconds,
proxyConfigured: spec.deployment.manager.imageBuild.httpProxy !== null || spec.deployment.manager.imageBuild.httpsProxy !== null,
},
gitops: {
branch: spec.gitops.branch,
path: spec.gitops.path,
renderedFileCount: renderedFiles.length,
renderedFilesDigest: renderedFiles.length === 0 ? null : renderedFilesDigest(renderedFiles),
},
valuesPrinted: false,
};
if (options.dryRun || !options.confirm) {
return {
ok: true,
command: "agentrun control-plane trigger-current",
mode: "dry-run",
mutation: false,
configPath: target.configPath,
target: agentRunLaneSummary(spec),
plan,
sourceProbe: compactCapture(probe, { full: probe.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }),
next: {
confirm: `bun scripts/cli.ts agentrun control-plane trigger-current --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
},
valuesPrinted: false,
};
}
if (options.wait) {
return await triggerCurrentYamlLaneConfirmed(config, spec, target.configPath, true);
}
return startAsyncAgentRunJob(
`agentrun_${spec.lane}_trigger_current`,
["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", spec.nodeId, "--lane", spec.lane, "--confirm", "--wait"],
`Run AgentRun ${spec.version} YAML-only trigger-current on ${spec.nodeId}`,
);
}