|
|
|
@@ -1,4 +1,5 @@
|
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
|
import { Buffer } from "node:buffer";
|
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
|
|
|
import pathPosix from "node:path/posix";
|
|
|
|
|
import type { UniDeskConfig } from "./config";
|
|
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
|
|
type OpsCommonOptions,
|
|
|
|
|
} from "./platform-infra-ops-library";
|
|
|
|
|
import { prepareLangBotSecretMaterial, readLangBotPostgresConninfo, readLangBotRuntimeConfig, readLangBotSecretMaterial } from "./platform-infra-langbot";
|
|
|
|
|
import { fingerprintValues } from "./platform-infra-public-service";
|
|
|
|
|
import { capture, compactCapture, fingerprintValues, parseJsonOutput, shQuote } from "./platform-infra-public-service";
|
|
|
|
|
|
|
|
|
|
const configFile = rootPath("config", "platform-infra", "wechat-archive.yaml");
|
|
|
|
|
const configLabel = "config/platform-infra/wechat-archive.yaml";
|
|
|
|
@@ -72,6 +73,7 @@ interface WechatArchiveConfig {
|
|
|
|
|
isolation: string;
|
|
|
|
|
requiredVersion: string;
|
|
|
|
|
installerAsset: string;
|
|
|
|
|
installMode: string;
|
|
|
|
|
installRoot: string;
|
|
|
|
|
dataRoot: string;
|
|
|
|
|
autoUpdatePolicy: string;
|
|
|
|
@@ -94,7 +96,8 @@ interface WechatArchiveConfig {
|
|
|
|
|
namespace: string;
|
|
|
|
|
createNamespace: boolean;
|
|
|
|
|
workload: { kind: string; name: string; serviceAccountName: string; replicas: number; containerName: string };
|
|
|
|
|
storage: { kind: string; name: string; mountPath: string; create: boolean };
|
|
|
|
|
storage: { kind: string; name: string; mountPath: string; size: string; create: boolean };
|
|
|
|
|
image: { repository: string; tag: string; pullPolicy: string; context: string; dockerfile: string; wcferryVersion: string };
|
|
|
|
|
wcfHost: string;
|
|
|
|
|
commandPort: number;
|
|
|
|
|
messagePort: number;
|
|
|
|
@@ -102,6 +105,7 @@ interface WechatArchiveConfig {
|
|
|
|
|
queueMode: string;
|
|
|
|
|
outboxMode: string;
|
|
|
|
|
archiveCallbackRef: string;
|
|
|
|
|
secretName: string;
|
|
|
|
|
readOnly: { enabled: boolean; sendCapability: boolean; allowedMethods: string[]; forbiddenMethods: string[] };
|
|
|
|
|
};
|
|
|
|
|
poc: { accountPolicy: string; observationWindowHours: number; requiredMessageTypes: string[] };
|
|
|
|
@@ -139,7 +143,7 @@ interface PullOptions extends OpsCommonOptions {
|
|
|
|
|
|
|
|
|
|
export function wechatArchiveHelp(): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
command: "platform-infra wechat-archive plan|apply|status|validate|pull",
|
|
|
|
|
command: "platform-infra wechat-archive plan|apply|status|validate|pull|wcf-host-*|collector-*",
|
|
|
|
|
output: "json",
|
|
|
|
|
usage: [
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive plan [--target G14]",
|
|
|
|
@@ -149,6 +153,14 @@ export function wechatArchiveHelp(): Record<string, unknown> {
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive validate [--target G14] [--full|--raw]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive pull --remote-path /UniDesk/WeChatArchive/... [--target G14] [--full|--raw]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive pull --fs-id <baiduFsId> [--local-path wechat-archive/pulls/file] [--target G14]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive wcf-host-prepare --confirm",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive wcf-host-status [--full|--raw]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive collector-image-build --confirm",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive collector-image-status [--full|--raw]",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive collector-apply --dry-run",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm",
|
|
|
|
|
"bun scripts/cli.ts platform-infra wechat-archive collector-status [--full|--raw]",
|
|
|
|
|
],
|
|
|
|
|
configTruth: configLabel,
|
|
|
|
|
boundary: {
|
|
|
|
@@ -168,6 +180,14 @@ export async function runWechatArchiveCommand(config: UniDeskConfig, args: strin
|
|
|
|
|
if (action === "status") return await status(parseOpsCommonOptions(args.slice(1)));
|
|
|
|
|
if (action === "validate") return await validate(parseOpsCommonOptions(args.slice(1)));
|
|
|
|
|
if (action === "pull") return await pull(parsePullOptions(args.slice(1)));
|
|
|
|
|
if (action === "wcf-host-prepare") return await wcfHostPrepare(config, parseOpsApplyOptions(args.slice(1)));
|
|
|
|
|
if (action === "wcf-host-start") return await wcfHostStart(config, parseOpsApplyOptions(args.slice(1)));
|
|
|
|
|
if (action === "wcf-host-status") return await wcfHostStatus(config, parseOpsCommonOptions(args.slice(1)));
|
|
|
|
|
if (action === "collector-plan") return collectorPlan(parseOpsCommonOptions(args.slice(1)));
|
|
|
|
|
if (action === "collector-image-build") return await collectorImageBuild(config, parseOpsApplyOptions(args.slice(1)));
|
|
|
|
|
if (action === "collector-image-status") return await collectorImageStatus(config, parseOpsCommonOptions(args.slice(1)));
|
|
|
|
|
if (action === "collector-apply") return await collectorApply(config, parseOpsApplyOptions(args.slice(1)));
|
|
|
|
|
if (action === "collector-status") return await collectorStatus(config, parseOpsCommonOptions(args.slice(1)));
|
|
|
|
|
return { ok: false, error: "unsupported-platform-infra-wechat-archive-command", args, help: wechatArchiveHelp() };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -206,6 +226,13 @@ function plan(options: OpsCommonOptions): Record<string, unknown> {
|
|
|
|
|
apply: `bun scripts/cli.ts platform-infra wechat-archive apply --target ${archive.target.id} --confirm`,
|
|
|
|
|
validate: `bun scripts/cli.ts platform-infra wechat-archive validate --target ${archive.target.id} --full`,
|
|
|
|
|
pull: `bun scripts/cli.ts platform-infra wechat-archive pull --remote-path ${archive.baiduNetdisk.archive.remoteRoot}/... --target ${archive.target.id}`,
|
|
|
|
|
personalWechatIngress: {
|
|
|
|
|
prepareWcfHost: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-prepare --confirm",
|
|
|
|
|
startWcfHost: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm",
|
|
|
|
|
buildCollectorImage: "bun scripts/cli.ts platform-infra wechat-archive collector-image-build --confirm",
|
|
|
|
|
applyCollector: "bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm",
|
|
|
|
|
status: "bun scripts/cli.ts platform-infra wechat-archive collector-status --full",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
@@ -360,6 +387,246 @@ async function pull(options: PullOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectorPlan(options: OpsCommonOptions): Record<string, unknown> {
|
|
|
|
|
const archive = readConfig();
|
|
|
|
|
assertTarget(archive, options.targetId);
|
|
|
|
|
const manifest = renderCollectorManifest(archive);
|
|
|
|
|
const policy = collectorPolicyChecks(archive, manifest);
|
|
|
|
|
return {
|
|
|
|
|
ok: policy.every((check) => check.ok),
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-plan",
|
|
|
|
|
mutation: false,
|
|
|
|
|
config: personalWechatIngressSummary(archive.personalWechatIngress),
|
|
|
|
|
manifest: {
|
|
|
|
|
namespace: archive.personalWechatIngress.collector.namespace,
|
|
|
|
|
image: collectorImageRef(archive.personalWechatIngress),
|
|
|
|
|
bytes: Buffer.byteLength(manifest, "utf8"),
|
|
|
|
|
sha256: fingerprintValues({ manifest }, ["manifest"]),
|
|
|
|
|
},
|
|
|
|
|
policy,
|
|
|
|
|
next: {
|
|
|
|
|
prepareWcfHost: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-prepare --confirm",
|
|
|
|
|
startWcfHost: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm",
|
|
|
|
|
buildImage: "bun scripts/cli.ts platform-infra wechat-archive collector-image-build --confirm",
|
|
|
|
|
dryRun: "bun scripts/cli.ts platform-infra wechat-archive collector-apply --dry-run",
|
|
|
|
|
apply: "bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm",
|
|
|
|
|
status: "bun scripts/cli.ts platform-infra wechat-archive collector-status --full",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function wcfHostPrepare(config: UniDeskConfig, options: OpsApplyOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const archive = readConfig();
|
|
|
|
|
assertTarget(archive, options.targetId);
|
|
|
|
|
if (options.dryRun) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
action: "platform-infra-wechat-archive-wcf-host-prepare",
|
|
|
|
|
mode: "dry-run",
|
|
|
|
|
mutation: false,
|
|
|
|
|
target: archive.personalWechatIngress.target,
|
|
|
|
|
scripts: windowsScriptSummary(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const result = await capture(config, archive.personalWechatIngress.target.windowsRoute, ["ps"], windowsScriptSyncAndStartPrepareScript(archive));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
|
|
|
action: "platform-infra-wechat-archive-wcf-host-prepare",
|
|
|
|
|
mode: "confirmed",
|
|
|
|
|
mutation: true,
|
|
|
|
|
target: archive.personalWechatIngress.target,
|
|
|
|
|
summary: parsed,
|
|
|
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
|
|
|
...(options.raw ? { raw: result } : {}),
|
|
|
|
|
next: {
|
|
|
|
|
status: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full",
|
|
|
|
|
start: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function wcfHostStart(config: UniDeskConfig, options: OpsApplyOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const archive = readConfig();
|
|
|
|
|
assertTarget(archive, options.targetId);
|
|
|
|
|
if (options.dryRun) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
action: "platform-infra-wechat-archive-wcf-host-start",
|
|
|
|
|
mode: "dry-run",
|
|
|
|
|
mutation: false,
|
|
|
|
|
target: archive.personalWechatIngress.target,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const result = await capture(config, archive.personalWechatIngress.target.windowsRoute, ["ps"], windowsRunManagedScript(archive, "start.ps1"));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
|
|
|
action: "platform-infra-wechat-archive-wcf-host-start",
|
|
|
|
|
mode: "confirmed",
|
|
|
|
|
mutation: true,
|
|
|
|
|
target: archive.personalWechatIngress.target,
|
|
|
|
|
summary: parsed,
|
|
|
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
|
|
|
...(options.raw ? { raw: result } : {}),
|
|
|
|
|
next: {
|
|
|
|
|
status: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full",
|
|
|
|
|
collectorApply: "bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function wcfHostStatus(config: UniDeskConfig, options: OpsCommonOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const archive = readConfig();
|
|
|
|
|
assertTarget(archive, options.targetId);
|
|
|
|
|
const result = await capture(config, archive.personalWechatIngress.target.windowsRoute, ["ps"], windowsRunManagedScript(archive, "status.ps1"));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
const status = parsed === null ? null : compactWcfHostStatus(parsed, options.full);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && parsed !== null && parsed.ok === true,
|
|
|
|
|
action: "platform-infra-wechat-archive-wcf-host-status",
|
|
|
|
|
target: archive.personalWechatIngress.target,
|
|
|
|
|
summary: status,
|
|
|
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
|
|
|
...(options.raw ? { raw: result } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function collectorImageBuild(config: UniDeskConfig, options: OpsApplyOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const archive = readConfig();
|
|
|
|
|
assertTarget(archive, options.targetId);
|
|
|
|
|
if (options.confirm && !options.wait) {
|
|
|
|
|
const job = startJob(
|
|
|
|
|
"platform_infra_wechat_archive_collector_image_build_d601",
|
|
|
|
|
["bun", "scripts/cli.ts", "platform-infra", "wechat-archive", "collector-image-build", "--confirm", "--wait"],
|
|
|
|
|
"Build and push the D601 k3s personal WeChat collector image through the controlled UniDesk CLI",
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-image-build",
|
|
|
|
|
mode: "async-job",
|
|
|
|
|
mutation: true,
|
|
|
|
|
job,
|
|
|
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (options.dryRun) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-image-build",
|
|
|
|
|
mode: "dry-run",
|
|
|
|
|
mutation: false,
|
|
|
|
|
image: collectorImageRef(archive.personalWechatIngress),
|
|
|
|
|
source: collectorSourceSummary(archive),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const result = await capture(config, archive.personalWechatIngress.target.hostRoute, ["script"], collectorImageBuildScript(archive));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-image-build",
|
|
|
|
|
mode: "confirmed",
|
|
|
|
|
mutation: true,
|
|
|
|
|
image: collectorImageRef(archive.personalWechatIngress),
|
|
|
|
|
summary: parsed,
|
|
|
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
|
|
|
...(options.raw ? { raw: result } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function collectorImageStatus(config: UniDeskConfig, options: OpsCommonOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const archive = readConfig();
|
|
|
|
|
assertTarget(archive, options.targetId);
|
|
|
|
|
const result = await capture(config, archive.personalWechatIngress.target.hostRoute, ["script"], collectorImageStatusScript(archive));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && parsed !== null && parsed.ok === true,
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-image-status",
|
|
|
|
|
image: collectorImageRef(archive.personalWechatIngress),
|
|
|
|
|
summary: parsed,
|
|
|
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
|
|
|
...(options.raw ? { raw: result } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function collectorApply(config: UniDeskConfig, options: OpsApplyOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const archive = readConfig();
|
|
|
|
|
assertTarget(archive, options.targetId);
|
|
|
|
|
const manifest = renderCollectorManifest(archive);
|
|
|
|
|
const policy = collectorPolicyChecks(archive, manifest);
|
|
|
|
|
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-wechat-archive-collector-apply", mode: "policy-blocked", policy };
|
|
|
|
|
if (options.confirm && !options.wait) {
|
|
|
|
|
const job = startJob(
|
|
|
|
|
"platform_infra_wechat_archive_collector_apply_d601",
|
|
|
|
|
["bun", "scripts/cli.ts", "platform-infra", "wechat-archive", "collector-apply", "--confirm", "--wait"],
|
|
|
|
|
"Apply D601 k3s personal WeChat collector objects through the controlled UniDesk CLI",
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-apply",
|
|
|
|
|
mode: "async-job",
|
|
|
|
|
mutation: true,
|
|
|
|
|
job,
|
|
|
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (options.dryRun) {
|
|
|
|
|
const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["script"], collectorApplyScript(archive, manifest, null, true));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-apply",
|
|
|
|
|
mode: "dry-run",
|
|
|
|
|
mutation: false,
|
|
|
|
|
policy,
|
|
|
|
|
target: archive.personalWechatIngress.target,
|
|
|
|
|
summary: parsed,
|
|
|
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
|
|
|
...(options.raw ? { raw: result } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const token = readArchiveCallbackToken(archive);
|
|
|
|
|
const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["script"], collectorApplyScript(archive, manifest, token.value, false));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-apply",
|
|
|
|
|
mode: "confirmed",
|
|
|
|
|
mutation: true,
|
|
|
|
|
policy,
|
|
|
|
|
target: archive.personalWechatIngress.target,
|
|
|
|
|
secret: {
|
|
|
|
|
sourceRef: token.sourceRef,
|
|
|
|
|
keyName: token.keyName,
|
|
|
|
|
targetSecret: archive.personalWechatIngress.collector.secretName,
|
|
|
|
|
fingerprint: token.fingerprint,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
},
|
|
|
|
|
summary: parsed,
|
|
|
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
|
|
|
...(options.raw ? { raw: result } : {}),
|
|
|
|
|
next: {
|
|
|
|
|
status: "bun scripts/cli.ts platform-infra wechat-archive collector-status --full",
|
|
|
|
|
wcfHostStatus: "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function collectorStatus(config: UniDeskConfig, options: OpsCommonOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const archive = readConfig();
|
|
|
|
|
assertTarget(archive, options.targetId);
|
|
|
|
|
const result = await capture(config, archive.personalWechatIngress.target.kubeRoute, ["script"], collectorStatusScript(archive, options.full));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && parsed !== null && parsed.ok === true,
|
|
|
|
|
action: "platform-infra-wechat-archive-collector-status",
|
|
|
|
|
target: archive.personalWechatIngress.target,
|
|
|
|
|
summary: parsed,
|
|
|
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
|
|
|
...(options.raw ? { raw: result } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readConfig(): WechatArchiveConfig {
|
|
|
|
|
const root = readYamlRecord(configFile, "platform-infra-wechat-archive");
|
|
|
|
|
const metadata = recordField(root, "metadata", configLabel);
|
|
|
|
@@ -475,6 +742,562 @@ function readConfig(): WechatArchiveConfig {
|
|
|
|
|
return config;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const collectorFieldManager = "unidesk-platform-infra-wechat-archive";
|
|
|
|
|
const windowsOpsRoot = "C:\\UniDesk\\personal-wechat\\ops";
|
|
|
|
|
|
|
|
|
|
function collectorImageRef(config: WechatArchiveConfig["personalWechatIngress"]): string {
|
|
|
|
|
return `${config.collector.image.repository}:${config.collector.image.tag}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function boolField(value: Record<string, unknown> | null, key: string, fallback: boolean): boolean {
|
|
|
|
|
if (value === null) return fallback;
|
|
|
|
|
const raw = value[key];
|
|
|
|
|
return typeof raw === "boolean" ? raw : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectorSourceSummary(archive: WechatArchiveConfig): Record<string, unknown> {
|
|
|
|
|
const ingress = archive.personalWechatIngress;
|
|
|
|
|
const dockerfile = resolveRepoPath(ingress.collector.image.dockerfile);
|
|
|
|
|
const collectorPy = resolveRepoPath(pathPosix.join(ingress.collector.image.context, "collector.py"));
|
|
|
|
|
return {
|
|
|
|
|
dockerfile: ingress.collector.image.dockerfile,
|
|
|
|
|
collectorPy: repoRelative(collectorPy),
|
|
|
|
|
dockerfileSha256: sha256File(dockerfile),
|
|
|
|
|
collectorPySha256: sha256File(collectorPy),
|
|
|
|
|
wcferryVersion: ingress.collector.image.wcferryVersion,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function windowsScriptSummary(): Record<string, unknown> {
|
|
|
|
|
const files = ["prepare.ps1", "start.ps1", "status.ps1", "wcf_host.py"].map((name) => {
|
|
|
|
|
const filePath = rootPath("scripts", "windows", "personal-wechat", name);
|
|
|
|
|
return { name, path: repoRelative(filePath), sha256: sha256File(filePath), bytes: readFileSync(filePath).byteLength };
|
|
|
|
|
});
|
|
|
|
|
return { targetRoot: windowsOpsRoot, files };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function b64(value: string): string {
|
|
|
|
|
return Buffer.from(value, "utf8").toString("base64");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fileB64(repoPath: string): string {
|
|
|
|
|
return readFileSync(resolveRepoPath(repoPath)).toString("base64");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function managedWindowsFiles(): Array<{ name: string; contentB64: string }> {
|
|
|
|
|
const base = rootPath("scripts", "windows", "personal-wechat");
|
|
|
|
|
return ["prepare.ps1", "start.ps1", "status.ps1", "wcf_host.py"].map((name) => ({
|
|
|
|
|
name,
|
|
|
|
|
contentB64: readFileSync(pathPosix.join(base, name)).toString("base64"),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function windowsScriptSyncBlock(): string {
|
|
|
|
|
const writes = managedWindowsFiles().map((file) => {
|
|
|
|
|
const target = `${windowsOpsRoot}\\${file.name}`;
|
|
|
|
|
return `
|
|
|
|
|
$target = ${psSingleQuote(target)}
|
|
|
|
|
$content = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${psSingleQuote(file.contentB64)}))
|
|
|
|
|
Set-Content -LiteralPath $target -Encoding UTF8 -Value $content
|
|
|
|
|
`;
|
|
|
|
|
}).join("\n");
|
|
|
|
|
return `
|
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
|
$OpsRoot = ${psSingleQuote(windowsOpsRoot)}
|
|
|
|
|
New-Item -ItemType Directory -Force $OpsRoot | Out-Null
|
|
|
|
|
${writes}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function windowsScriptSyncAndStartPrepareScript(_archive: WechatArchiveConfig): string {
|
|
|
|
|
const prepare = `${windowsOpsRoot}\\prepare.ps1`;
|
|
|
|
|
const stateRoot = "C:\\UniDesk\\personal-wechat\\wcf-state";
|
|
|
|
|
const stdout = `${stateRoot}\\prepare.stdout.log`;
|
|
|
|
|
const stderr = `${stateRoot}\\prepare.stderr.log`;
|
|
|
|
|
const resultFile = `${stateRoot}\\prepare-result.json`;
|
|
|
|
|
return `${windowsScriptSyncBlock()}
|
|
|
|
|
$StateRoot = ${psSingleQuote(stateRoot)}
|
|
|
|
|
New-Item -ItemType Directory -Force $StateRoot | Out-Null
|
|
|
|
|
if (Test-Path ${psSingleQuote(resultFile)}) { Remove-Item -Force ${psSingleQuote(resultFile)} }
|
|
|
|
|
$proc = Start-Process -FilePath "powershell.exe" -ArgumentList @("-NoProfile","-ExecutionPolicy","Bypass","-File",${psSingleQuote(prepare)}) -WindowStyle Hidden -PassThru -RedirectStandardOutput ${psSingleQuote(stdout)} -RedirectStandardError ${psSingleQuote(stderr)}
|
|
|
|
|
[pscustomobject]@{
|
|
|
|
|
ok = $true
|
|
|
|
|
started = $true
|
|
|
|
|
pid = $proc.Id
|
|
|
|
|
opsRoot = $OpsRoot
|
|
|
|
|
stdout = ${psSingleQuote(stdout)}
|
|
|
|
|
stderr = ${psSingleQuote(stderr)}
|
|
|
|
|
result = ${psSingleQuote(resultFile)}
|
|
|
|
|
statusCommand = "bun scripts/cli.ts platform-infra wechat-archive wcf-host-status --full"
|
|
|
|
|
} | ConvertTo-Json -Depth 6
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function windowsRunManagedScript(_archive: WechatArchiveConfig, scriptName: "start.ps1" | "status.ps1"): string {
|
|
|
|
|
const script = `${windowsOpsRoot}\\${scriptName}`;
|
|
|
|
|
return `${windowsScriptSyncBlock()}
|
|
|
|
|
& ${psSingleQuote(script)}
|
|
|
|
|
if ($LASTEXITCODE -ne $null -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function psSingleQuote(value: string): string {
|
|
|
|
|
return `'${value.replaceAll("'", "''")}'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactWcfHostStatus(parsed: Record<string, unknown>, full: boolean): Record<string, unknown> {
|
|
|
|
|
const status = typeof parsed.status === "object" && parsed.status !== null && !Array.isArray(parsed.status)
|
|
|
|
|
? parsed.status as Record<string, unknown>
|
|
|
|
|
: null;
|
|
|
|
|
const prepare = typeof parsed.prepare === "object" && parsed.prepare !== null && !Array.isArray(parsed.prepare)
|
|
|
|
|
? parsed.prepare as Record<string, unknown>
|
|
|
|
|
: null;
|
|
|
|
|
return {
|
|
|
|
|
ok: parsed.ok,
|
|
|
|
|
prepared: parsed.prepared,
|
|
|
|
|
running: parsed.running,
|
|
|
|
|
pid: parsed.pid,
|
|
|
|
|
ports: parsed.ports,
|
|
|
|
|
login: status === null ? null : {
|
|
|
|
|
isLogin: status.isLogin,
|
|
|
|
|
qrcodePresent: status.qrcodePresent,
|
|
|
|
|
qrcode: full ? status.qrcode : undefined,
|
|
|
|
|
ts: status.ts,
|
|
|
|
|
},
|
|
|
|
|
prepare: prepare === null ? null : {
|
|
|
|
|
ok: prepare.ok,
|
|
|
|
|
wechatExe: prepare.wechatExe,
|
|
|
|
|
installAttempted: prepare.installAttempted,
|
|
|
|
|
installExitCode: prepare.installExitCode,
|
|
|
|
|
wcferryVersion: prepare.wcferryVersion,
|
|
|
|
|
next: prepare.next,
|
|
|
|
|
},
|
|
|
|
|
paths: parsed.paths,
|
|
|
|
|
logs: full ? parsed.logs : undefined,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderCollectorManifest(archive: WechatArchiveConfig): string {
|
|
|
|
|
const ingress = archive.personalWechatIngress;
|
|
|
|
|
const collector = ingress.collector;
|
|
|
|
|
const labels = [
|
|
|
|
|
"app.kubernetes.io/name: personal-wechat-collector",
|
|
|
|
|
"app.kubernetes.io/part-of: platform-infra",
|
|
|
|
|
"app.kubernetes.io/managed-by: unidesk",
|
|
|
|
|
"unidesk.ai/component: personal-wechat-collector",
|
|
|
|
|
].join("\n ");
|
|
|
|
|
return `apiVersion: v1
|
|
|
|
|
kind: ServiceAccount
|
|
|
|
|
metadata:
|
|
|
|
|
name: ${collector.workload.serviceAccountName}
|
|
|
|
|
namespace: ${collector.namespace}
|
|
|
|
|
labels:
|
|
|
|
|
${labels}
|
|
|
|
|
---
|
|
|
|
|
apiVersion: v1
|
|
|
|
|
kind: PersistentVolumeClaim
|
|
|
|
|
metadata:
|
|
|
|
|
name: ${collector.storage.name}
|
|
|
|
|
namespace: ${collector.namespace}
|
|
|
|
|
labels:
|
|
|
|
|
${labels}
|
|
|
|
|
spec:
|
|
|
|
|
accessModes:
|
|
|
|
|
- ReadWriteOnce
|
|
|
|
|
resources:
|
|
|
|
|
requests:
|
|
|
|
|
storage: ${collector.storage.size}
|
|
|
|
|
---
|
|
|
|
|
apiVersion: v1
|
|
|
|
|
kind: ConfigMap
|
|
|
|
|
metadata:
|
|
|
|
|
name: personal-wechat-collector-config
|
|
|
|
|
namespace: ${collector.namespace}
|
|
|
|
|
labels:
|
|
|
|
|
${labels}
|
|
|
|
|
data:
|
|
|
|
|
WCF_HOST: "${collector.wcfHost}"
|
|
|
|
|
WCF_COMMAND_PORT: "${collector.commandPort}"
|
|
|
|
|
STATE_ROOT: "${collector.stateRoot}"
|
|
|
|
|
ARCHIVE_CALLBACK_URL: "${archive.archiveCallback.publicUrl}"
|
|
|
|
|
ARCHIVE_REMOTE_ROOT: "${archive.baiduNetdisk.archive.remoteRoot}"
|
|
|
|
|
ARCHIVE_TIMEZONE: "${archive.baiduNetdisk.archive.timezone}"
|
|
|
|
|
POLL_IDLE_SECONDS: "1"
|
|
|
|
|
STATUS_INTERVAL_SECONDS: "15"
|
|
|
|
|
---
|
|
|
|
|
apiVersion: apps/v1
|
|
|
|
|
kind: Deployment
|
|
|
|
|
metadata:
|
|
|
|
|
name: ${collector.workload.name}
|
|
|
|
|
namespace: ${collector.namespace}
|
|
|
|
|
labels:
|
|
|
|
|
${labels}
|
|
|
|
|
spec:
|
|
|
|
|
replicas: ${collector.workload.replicas}
|
|
|
|
|
selector:
|
|
|
|
|
matchLabels:
|
|
|
|
|
app.kubernetes.io/name: personal-wechat-collector
|
|
|
|
|
unidesk.ai/component: personal-wechat-collector
|
|
|
|
|
template:
|
|
|
|
|
metadata:
|
|
|
|
|
labels:
|
|
|
|
|
app.kubernetes.io/name: personal-wechat-collector
|
|
|
|
|
app.kubernetes.io/part-of: platform-infra
|
|
|
|
|
unidesk.ai/component: personal-wechat-collector
|
|
|
|
|
annotations:
|
|
|
|
|
unidesk.ai/wcf-host: "${collector.wcfHost}:${collector.commandPort}"
|
|
|
|
|
unidesk.ai/read-only: "${collector.readOnly.enabled}"
|
|
|
|
|
unidesk.ai/send-capability: "${collector.readOnly.sendCapability}"
|
|
|
|
|
unidesk.ai/archive-callback-ref: "${collector.archiveCallbackRef}"
|
|
|
|
|
spec:
|
|
|
|
|
serviceAccountName: ${collector.workload.serviceAccountName}
|
|
|
|
|
containers:
|
|
|
|
|
- name: ${collector.workload.containerName}
|
|
|
|
|
image: ${collectorImageRef(ingress)}
|
|
|
|
|
imagePullPolicy: ${collector.image.pullPolicy}
|
|
|
|
|
envFrom:
|
|
|
|
|
- configMapRef:
|
|
|
|
|
name: personal-wechat-collector-config
|
|
|
|
|
env:
|
|
|
|
|
- name: ARCHIVE_CALLBACK_TOKEN
|
|
|
|
|
valueFrom:
|
|
|
|
|
secretKeyRef:
|
|
|
|
|
name: ${collector.secretName}
|
|
|
|
|
key: ${archive.archiveCallback.tokenKey}
|
|
|
|
|
volumeMounts:
|
|
|
|
|
- name: state
|
|
|
|
|
mountPath: ${collector.storage.mountPath}
|
|
|
|
|
volumes:
|
|
|
|
|
- name: state
|
|
|
|
|
persistentVolumeClaim:
|
|
|
|
|
claimName: ${collector.storage.name}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectorPolicyChecks(archive: WechatArchiveConfig, manifest: string): Array<Record<string, unknown>> {
|
|
|
|
|
const collector = archive.personalWechatIngress.collector;
|
|
|
|
|
return [
|
|
|
|
|
{ name: "no-namespace-create", ok: !/^\s*kind:\s*Namespace\s*$/mu.test(manifest) && collector.createNamespace === false, detail: "collector apply must reuse the existing platform-infra namespace and must not render/create Namespace." },
|
|
|
|
|
{ name: "target-d601-k3s", ok: archive.personalWechatIngress.target.kubeRoute === "D601:k3s" && collector.runtime === "d601-k3s", detail: "collector runtime is D601 native k3s." },
|
|
|
|
|
{ name: "namespace-platform-infra", ok: collector.namespace === "platform-infra", detail: "collector deploys into the existing platform-infra namespace." },
|
|
|
|
|
{ name: "no-public-exposure", ok: !/^\s*kind:\s*(Ingress|Service)\s*$/mu.test(manifest) && archive.personalWechatIngress.wcfHost.firewall.publicExposure === false, detail: "collector has no public ingress, node port, load balancer or service." },
|
|
|
|
|
{ name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(manifest), detail: "collector must not use hostNetwork." },
|
|
|
|
|
{ name: "read-only-methods", ok: collector.readOnly.enabled === true && collector.readOnly.sendCapability === false, detail: "collector only invokes read-only WCF methods in source." },
|
|
|
|
|
{ name: "callback-token-source-ref", ok: Boolean(archive.archiveCallback.tokenSourceRef && archive.archiveCallback.tokenKey && collector.secretName), detail: "callback token is synced from YAML-declared sourceRef into a runtime Secret." },
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectorImageBuildScript(archive: WechatArchiveConfig): string {
|
|
|
|
|
const ingress = archive.personalWechatIngress;
|
|
|
|
|
const dockerfileB64 = fileB64(ingress.collector.image.dockerfile);
|
|
|
|
|
const collectorB64 = fileB64(pathPosix.join(ingress.collector.image.context, "collector.py"));
|
|
|
|
|
const image = collectorImageRef(ingress);
|
|
|
|
|
const jobRoot = "/tmp/unidesk-personal-wechat-collector-build";
|
|
|
|
|
return `
|
|
|
|
|
set -u
|
|
|
|
|
job_root=${shQuote(jobRoot)}
|
|
|
|
|
job_dir="$job_root/work"
|
|
|
|
|
status_file="$job_root/status.json"
|
|
|
|
|
runner="$job_root/build-runner.sh"
|
|
|
|
|
mkdir -p "$job_root"
|
|
|
|
|
if [ -f "$job_root/pid" ] && kill -0 "$(cat "$job_root/pid")" 2>/dev/null; then
|
|
|
|
|
python3 - "$status_file" "$(cat "$job_root/pid")" <<'PY'
|
|
|
|
|
import json, sys
|
|
|
|
|
payload = {"ok": True, "started": False, "alreadyRunning": True, "pid": sys.argv[2], "statusFile": sys.argv[1]}
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
PY
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
rm -rf "$job_dir"
|
|
|
|
|
mkdir -p "$job_dir"
|
|
|
|
|
printf '%s' '${dockerfileB64}' | base64 -d > "$job_dir/Dockerfile"
|
|
|
|
|
printf '%s' '${collectorB64}' | base64 -d > "$job_dir/collector.py"
|
|
|
|
|
build_log="$job_dir/docker-build.log"
|
|
|
|
|
push_log="$job_dir/docker-push.log"
|
|
|
|
|
cat >"$runner" <<'SH'
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
set +e
|
|
|
|
|
job_dir=${shQuote(`${jobRoot}/work`)}
|
|
|
|
|
status_file=${shQuote(`${jobRoot}/status.json`)}
|
|
|
|
|
build_log="$job_dir/docker-build.log"
|
|
|
|
|
push_log="$job_dir/docker-push.log"
|
|
|
|
|
python3 - "$status_file" "$build_log" "$push_log" <<'PY'
|
|
|
|
|
import json, sys, time
|
|
|
|
|
payload = {"ok": False, "status": "running", "startedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "image": "${image}", "logs": {"build": sys.argv[2], "push": sys.argv[3]}}
|
|
|
|
|
open(sys.argv[1], "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
PY
|
|
|
|
|
docker build --build-arg WCFERRY_VERSION=${shQuote(ingress.collector.image.wcferryVersion)} -t ${shQuote(image)} "$job_dir" >"$build_log" 2>&1
|
|
|
|
|
build_rc=$?
|
|
|
|
|
if [ "$build_rc" -eq 0 ]; then
|
|
|
|
|
docker push ${shQuote(image)} >"$push_log" 2>&1
|
|
|
|
|
push_rc=$?
|
|
|
|
|
else
|
|
|
|
|
: >"$push_log"
|
|
|
|
|
push_rc=1
|
|
|
|
|
fi
|
|
|
|
|
python3 - "$status_file" "$build_rc" "$push_rc" "$build_log" "$push_log" <<'PY'
|
|
|
|
|
import json, sys
|
|
|
|
|
status_file, build_rc, push_rc = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
|
|
|
|
|
def text(path, limit=8000):
|
|
|
|
|
try:
|
|
|
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return ""
|
|
|
|
|
payload = {
|
|
|
|
|
"ok": build_rc == 0 and push_rc == 0,
|
|
|
|
|
"status": "succeeded" if build_rc == 0 and push_rc == 0 else "failed",
|
|
|
|
|
"image": "${image}",
|
|
|
|
|
"wcferryVersion": "${ingress.collector.image.wcferryVersion}",
|
|
|
|
|
"steps": {
|
|
|
|
|
"build": {"exitCode": build_rc, "log": sys.argv[4], "logTail": text(sys.argv[4])},
|
|
|
|
|
"push": {"exitCode": push_rc, "log": sys.argv[5], "logTail": text(sys.argv[5])},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
open(status_file, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False, indent=2) + "\\n")
|
|
|
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
|
|
|
PY
|
|
|
|
|
SH
|
|
|
|
|
chmod +x "$runner"
|
|
|
|
|
nohup "$runner" >"$job_root/nohup.out" 2>"$job_root/nohup.err" &
|
|
|
|
|
pid=$!
|
|
|
|
|
printf '%s' "$pid" >"$job_root/pid"
|
|
|
|
|
python3 - "$status_file" "$pid" "$job_root" <<'PY'
|
|
|
|
|
import json, sys
|
|
|
|
|
payload = {"ok": True, "started": True, "pid": sys.argv[2], "statusFile": sys.argv[1], "jobRoot": sys.argv[3], "statusCommand": "bun scripts/cli.ts platform-infra wechat-archive collector-image-status --full"}
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
PY
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectorImageStatusScript(archive: WechatArchiveConfig): string {
|
|
|
|
|
const image = collectorImageRef(archive.personalWechatIngress);
|
|
|
|
|
return `
|
|
|
|
|
set -u
|
|
|
|
|
tmp="$(mktemp -d)"
|
|
|
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
|
job_root=/tmp/unidesk-personal-wechat-collector-build
|
|
|
|
|
status_file="$job_root/status.json"
|
|
|
|
|
pid_file="$job_root/pid"
|
|
|
|
|
running=0
|
|
|
|
|
if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
|
|
|
|
|
running=1
|
|
|
|
|
fi
|
|
|
|
|
docker image inspect ${shQuote(image)} >"$tmp/local.json" 2>"$tmp/local.err"
|
|
|
|
|
local_rc=$?
|
|
|
|
|
docker manifest inspect ${shQuote(image)} >"$tmp/manifest.json" 2>"$tmp/manifest.err"
|
|
|
|
|
manifest_rc=$?
|
|
|
|
|
python3 - "$local_rc" "$manifest_rc" "$running" "$status_file" "$tmp/local.json" "$tmp/local.err" "$tmp/manifest.json" "$tmp/manifest.err" <<'PY'
|
|
|
|
|
import json, sys
|
|
|
|
|
local_rc, manifest_rc, running = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3] == "1"
|
|
|
|
|
def load(path):
|
|
|
|
|
try:
|
|
|
|
|
return json.load(open(path, encoding="utf-8"))
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
def text(path):
|
|
|
|
|
try:
|
|
|
|
|
return open(path, encoding="utf-8", errors="replace").read()[-2000:]
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return ""
|
|
|
|
|
build_status = load(sys.argv[4])
|
|
|
|
|
local = load(sys.argv[5])
|
|
|
|
|
payload = {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"image": "${image}",
|
|
|
|
|
"build": build_status,
|
|
|
|
|
"buildRunning": running,
|
|
|
|
|
"localPresent": local_rc == 0,
|
|
|
|
|
"registryManifestPresent": manifest_rc == 0,
|
|
|
|
|
"local": {
|
|
|
|
|
"id": local[0].get("Id") if isinstance(local, list) and local else None,
|
|
|
|
|
"created": local[0].get("Created") if isinstance(local, list) and local else None,
|
|
|
|
|
"repoDigests": local[0].get("RepoDigests") if isinstance(local, list) and local else [],
|
|
|
|
|
},
|
|
|
|
|
"errors": {
|
|
|
|
|
"local": text(sys.argv[6]) if local_rc != 0 else "",
|
|
|
|
|
"manifest": text(sys.argv[8]) if manifest_rc != 0 else "",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
PY
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectorApplyScript(archive: WechatArchiveConfig, manifest: string, token: string | null, dryRun: boolean): string {
|
|
|
|
|
const ingress = archive.personalWechatIngress;
|
|
|
|
|
const collector = ingress.collector;
|
|
|
|
|
const manifestB64 = b64(manifest);
|
|
|
|
|
const tokenB64 = token === null ? "" : b64(token);
|
|
|
|
|
const mode = dryRun ? "dry-run" : "confirmed";
|
|
|
|
|
return `
|
|
|
|
|
set -u
|
|
|
|
|
tmp="$(mktemp -d)"
|
|
|
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
|
manifest="$tmp/personal-wechat-collector.yaml"
|
|
|
|
|
printf '%s' '${manifestB64}' | base64 -d > "$manifest"
|
|
|
|
|
kubectl get namespace ${collector.namespace} >"$tmp/ns.out" 2>"$tmp/ns.err"
|
|
|
|
|
ns_rc=$?
|
|
|
|
|
secret_rc=0
|
|
|
|
|
apply_rc=1
|
|
|
|
|
if [ "$ns_rc" -eq 0 ]; then
|
|
|
|
|
if [ ${dryRun ? "1" : "0"} -eq 1 ]; then
|
|
|
|
|
kubectl apply --dry-run=client -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
|
|
|
|
|
apply_rc=$?
|
|
|
|
|
else
|
|
|
|
|
printf '%s' '${tokenB64}' | base64 -d > "$tmp/archive-token"
|
|
|
|
|
kubectl -n ${collector.namespace} create secret generic ${collector.secretName} \\
|
|
|
|
|
--from-file=${archive.archiveCallback.tokenKey}="$tmp/archive-token" \\
|
|
|
|
|
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${collectorFieldManager} -f - >"$tmp/secret.out" 2>"$tmp/secret.err"
|
|
|
|
|
secret_rc=$?
|
|
|
|
|
if [ "$secret_rc" -eq 0 ]; then
|
|
|
|
|
kubectl apply --server-side --force-conflicts --field-manager=${collectorFieldManager} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
|
|
|
|
|
apply_rc=$?
|
|
|
|
|
else
|
|
|
|
|
: >"$tmp/apply.out"
|
|
|
|
|
printf '%s\\n' 'skipped because secret sync failed' >"$tmp/apply.err"
|
|
|
|
|
apply_rc=1
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
: >"$tmp/secret.out"; : >"$tmp/secret.err"; : >"$tmp/apply.out"
|
|
|
|
|
printf '%s\\n' 'skipped because declared namespace does not exist' >"$tmp/apply.err"
|
|
|
|
|
fi
|
|
|
|
|
python3 - "$ns_rc" "$secret_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/secret.out" "$tmp/secret.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY'
|
|
|
|
|
import json, sys
|
|
|
|
|
ns_rc, secret_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3])
|
|
|
|
|
def text(path, limit=8000):
|
|
|
|
|
try:
|
|
|
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return ""
|
|
|
|
|
payload = {
|
|
|
|
|
"ok": ns_rc == 0 and secret_rc == 0 and apply_rc == 0,
|
|
|
|
|
"mode": "${mode}",
|
|
|
|
|
"namespace": "${collector.namespace}",
|
|
|
|
|
"image": "${collectorImageRef(ingress)}",
|
|
|
|
|
"secret": {"name": "${collector.secretName}", "key": "${archive.archiveCallback.tokenKey}", "applied": ${dryRun ? "False" : "True"}, "valuesPrinted": False},
|
|
|
|
|
"steps": {
|
|
|
|
|
"namespace": {"exitCode": ns_rc, "stdout": text(sys.argv[4]), "stderr": text(sys.argv[5])},
|
|
|
|
|
"secret": {"exitCode": secret_rc, "stdout": text(sys.argv[6]), "stderr": text(sys.argv[7])},
|
|
|
|
|
"apply": {"exitCode": apply_rc, "stdout": text(sys.argv[8]), "stderr": text(sys.argv[9])},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
|
|
|
PY
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectorStatusScript(archive: WechatArchiveConfig, full: boolean): string {
|
|
|
|
|
const collector = archive.personalWechatIngress.collector;
|
|
|
|
|
return `
|
|
|
|
|
set -u
|
|
|
|
|
tmp="$(mktemp -d)"
|
|
|
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
|
capture() {
|
|
|
|
|
name="$1"
|
|
|
|
|
shift
|
|
|
|
|
"$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err"
|
|
|
|
|
printf '%s' "$?" >"$tmp/$name.rc"
|
|
|
|
|
}
|
|
|
|
|
capture namespace kubectl get namespace ${collector.namespace}
|
|
|
|
|
capture deploy kubectl -n ${collector.namespace} get deploy ${collector.workload.name}
|
|
|
|
|
capture pods kubectl -n ${collector.namespace} get pods -l app.kubernetes.io/name=personal-wechat-collector
|
|
|
|
|
capture pvc kubectl -n ${collector.namespace} get pvc ${collector.storage.name}
|
|
|
|
|
capture secret kubectl -n ${collector.namespace} get secret ${collector.secretName}
|
|
|
|
|
capture configmap kubectl -n ${collector.namespace} get configmap personal-wechat-collector-config
|
|
|
|
|
capture events kubectl -n ${collector.namespace} get events --sort-by=.lastTimestamp
|
|
|
|
|
pod="$(kubectl -n ${collector.namespace} get pods -l app.kubernetes.io/name=personal-wechat-collector -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)"
|
|
|
|
|
if [ -n "$pod" ]; then
|
|
|
|
|
kubectl -n ${collector.namespace} logs "$pod" --tail ${full ? "160" : "40"} >"$tmp/logs.txt" 2>"$tmp/logs.err"
|
|
|
|
|
logs_rc=$?
|
|
|
|
|
else
|
|
|
|
|
: >"$tmp/logs.txt"
|
|
|
|
|
printf '%s\\n' 'collector pod not found' >"$tmp/logs.err"
|
|
|
|
|
logs_rc=1
|
|
|
|
|
fi
|
|
|
|
|
python3 - "$tmp" "$logs_rc" "$pod" <<'PY'
|
|
|
|
|
import json, os, sys
|
|
|
|
|
tmp, logs_rc, pod = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
|
|
|
|
def load(name):
|
|
|
|
|
try:
|
|
|
|
|
return json.load(open(os.path.join(tmp, f"{name}.json"), encoding="utf-8"))
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
def rc(name):
|
|
|
|
|
try:
|
|
|
|
|
return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1")
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return 1
|
|
|
|
|
def text(path, limit=8000):
|
|
|
|
|
try:
|
|
|
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return ""
|
|
|
|
|
deploy = load("deploy") or {}
|
|
|
|
|
status = deploy.get("status", {}) if isinstance(deploy, dict) else {}
|
|
|
|
|
spec = deploy.get("spec", {}) if isinstance(deploy, dict) else {}
|
|
|
|
|
pods = load("pods") or {"items": []}
|
|
|
|
|
events = load("events") or {"items": []}
|
|
|
|
|
pod_items = pods.get("items", []) if isinstance(pods, dict) else []
|
|
|
|
|
def pod_summary(item):
|
|
|
|
|
pod_status = item.get("status", {})
|
|
|
|
|
containers = pod_status.get("containerStatuses") or []
|
|
|
|
|
return {
|
|
|
|
|
"name": item.get("metadata", {}).get("name"),
|
|
|
|
|
"phase": pod_status.get("phase"),
|
|
|
|
|
"podIP": pod_status.get("podIP"),
|
|
|
|
|
"nodeName": item.get("spec", {}).get("nodeName"),
|
|
|
|
|
"containers": [
|
|
|
|
|
{
|
|
|
|
|
"name": c.get("name"),
|
|
|
|
|
"ready": c.get("ready"),
|
|
|
|
|
"restartCount": c.get("restartCount"),
|
|
|
|
|
"state": c.get("state"),
|
|
|
|
|
"lastState": c.get("lastState"),
|
|
|
|
|
}
|
|
|
|
|
for c in containers
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
def event_summary(event):
|
|
|
|
|
involved = event.get("involvedObject", {})
|
|
|
|
|
if involved.get("name") and "personal-wechat-collector" not in involved.get("name", ""):
|
|
|
|
|
return None
|
|
|
|
|
return {
|
|
|
|
|
"type": event.get("type"),
|
|
|
|
|
"reason": event.get("reason"),
|
|
|
|
|
"message": str(event.get("message") or "")[-600:],
|
|
|
|
|
"count": event.get("count"),
|
|
|
|
|
"lastTimestamp": event.get("lastTimestamp") or event.get("eventTime"),
|
|
|
|
|
"involvedObject": {"kind": involved.get("kind"), "name": involved.get("name")},
|
|
|
|
|
}
|
|
|
|
|
related_events = [item for item in (event_summary(e) for e in events.get("items", [])[-30:]) if item]
|
|
|
|
|
ready = status.get("readyReplicas", 0) >= spec.get("replicas", 1) and rc("deploy") == 0
|
|
|
|
|
payload = {
|
|
|
|
|
"ok": rc("namespace") == 0 and rc("deploy") == 0,
|
|
|
|
|
"namespace": "${collector.namespace}",
|
|
|
|
|
"deployment": {
|
|
|
|
|
"name": "${collector.workload.name}",
|
|
|
|
|
"replicas": spec.get("replicas"),
|
|
|
|
|
"readyReplicas": status.get("readyReplicas", 0),
|
|
|
|
|
"updatedReplicas": status.get("updatedReplicas", 0),
|
|
|
|
|
"availableReplicas": status.get("availableReplicas", 0),
|
|
|
|
|
"ready": ready,
|
|
|
|
|
},
|
|
|
|
|
"pvc": {"present": rc("pvc") == 0, "name": "${collector.storage.name}"},
|
|
|
|
|
"secret": {"present": rc("secret") == 0, "name": "${collector.secretName}", "valuesPrinted": False},
|
|
|
|
|
"configMap": {"present": rc("configmap") == 0, "name": "personal-wechat-collector-config"},
|
|
|
|
|
"pods": [pod_summary(item) for item in pod_items],
|
|
|
|
|
"logs": {"pod": pod, "exitCode": logs_rc, "tail": text(os.path.join(tmp, "logs.txt")), "stderrTail": text(os.path.join(tmp, "logs.err"), 2000)},
|
|
|
|
|
"events": related_events[-12:],
|
|
|
|
|
"valuesPrinted": False,
|
|
|
|
|
}
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
PY
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateConfig(config: WechatArchiveConfig): void {
|
|
|
|
|
if (config.version !== 1) throw new Error(`${configLabel}.version must be 1`);
|
|
|
|
|
if (config.target.id !== "G14" || config.target.route !== "G14:k3s") throw new Error(`${configLabel}.target currently supports only G14:k3s`);
|
|
|
|
@@ -575,6 +1398,7 @@ function parsePersonalWechatIngress(raw: Record<string, unknown>): WechatArchive
|
|
|
|
|
const collector = recordField(raw, "collector", `${configLabel}.personalWechatIngress`);
|
|
|
|
|
const workload = recordField(collector, "workload", `${configLabel}.personalWechatIngress.collector`);
|
|
|
|
|
const storage = recordField(collector, "storage", `${configLabel}.personalWechatIngress.collector`);
|
|
|
|
|
const image = recordField(collector, "image", `${configLabel}.personalWechatIngress.collector`);
|
|
|
|
|
const readOnly = recordField(collector, "readOnly", `${configLabel}.personalWechatIngress.collector`);
|
|
|
|
|
const poc = recordField(raw, "poc", `${configLabel}.personalWechatIngress`);
|
|
|
|
|
return {
|
|
|
|
@@ -590,6 +1414,7 @@ function parsePersonalWechatIngress(raw: Record<string, unknown>): WechatArchive
|
|
|
|
|
isolation: stringField(pcWechat, "isolation", `${configLabel}.personalWechatIngress.pcWechat`),
|
|
|
|
|
requiredVersion: stringField(pcWechat, "requiredVersion", `${configLabel}.personalWechatIngress.pcWechat`),
|
|
|
|
|
installerAsset: stringField(pcWechat, "installerAsset", `${configLabel}.personalWechatIngress.pcWechat`),
|
|
|
|
|
installMode: stringField(pcWechat, "installMode", `${configLabel}.personalWechatIngress.pcWechat`),
|
|
|
|
|
installRoot: stringField(pcWechat, "installRoot", `${configLabel}.personalWechatIngress.pcWechat`),
|
|
|
|
|
dataRoot: stringField(pcWechat, "dataRoot", `${configLabel}.personalWechatIngress.pcWechat`),
|
|
|
|
|
autoUpdatePolicy: stringField(pcWechat, "autoUpdatePolicy", `${configLabel}.personalWechatIngress.pcWechat`),
|
|
|
|
@@ -625,8 +1450,17 @@ function parsePersonalWechatIngress(raw: Record<string, unknown>): WechatArchive
|
|
|
|
|
kind: stringField(storage, "kind", `${configLabel}.personalWechatIngress.collector.storage`),
|
|
|
|
|
name: stringField(storage, "name", `${configLabel}.personalWechatIngress.collector.storage`),
|
|
|
|
|
mountPath: stringField(storage, "mountPath", `${configLabel}.personalWechatIngress.collector.storage`),
|
|
|
|
|
size: stringField(storage, "size", `${configLabel}.personalWechatIngress.collector.storage`),
|
|
|
|
|
create: booleanField(storage, "create", `${configLabel}.personalWechatIngress.collector.storage`),
|
|
|
|
|
},
|
|
|
|
|
image: {
|
|
|
|
|
repository: stringField(image, "repository", `${configLabel}.personalWechatIngress.collector.image`),
|
|
|
|
|
tag: stringField(image, "tag", `${configLabel}.personalWechatIngress.collector.image`),
|
|
|
|
|
pullPolicy: stringField(image, "pullPolicy", `${configLabel}.personalWechatIngress.collector.image`),
|
|
|
|
|
context: stringField(image, "context", `${configLabel}.personalWechatIngress.collector.image`),
|
|
|
|
|
dockerfile: stringField(image, "dockerfile", `${configLabel}.personalWechatIngress.collector.image`),
|
|
|
|
|
wcferryVersion: stringField(image, "wcferryVersion", `${configLabel}.personalWechatIngress.collector.image`),
|
|
|
|
|
},
|
|
|
|
|
wcfHost: stringField(collector, "wcfHost", `${configLabel}.personalWechatIngress.collector`),
|
|
|
|
|
commandPort: numberField(collector, "commandPort", `${configLabel}.personalWechatIngress.collector`),
|
|
|
|
|
messagePort: numberField(collector, "messagePort", `${configLabel}.personalWechatIngress.collector`),
|
|
|
|
@@ -634,6 +1468,7 @@ function parsePersonalWechatIngress(raw: Record<string, unknown>): WechatArchive
|
|
|
|
|
queueMode: stringField(collector, "queueMode", `${configLabel}.personalWechatIngress.collector`),
|
|
|
|
|
outboxMode: stringField(collector, "outboxMode", `${configLabel}.personalWechatIngress.collector`),
|
|
|
|
|
archiveCallbackRef: stringField(collector, "archiveCallbackRef", `${configLabel}.personalWechatIngress.collector`),
|
|
|
|
|
secretName: stringField(collector, "secretName", `${configLabel}.personalWechatIngress.collector`),
|
|
|
|
|
readOnly: {
|
|
|
|
|
enabled: booleanField(readOnly, "enabled", `${configLabel}.personalWechatIngress.collector.readOnly`),
|
|
|
|
|
sendCapability: booleanField(readOnly, "sendCapability", `${configLabel}.personalWechatIngress.collector.readOnly`),
|
|
|
|
@@ -669,6 +1504,12 @@ function validatePersonalWechatIngress(config: WechatArchiveConfig["personalWech
|
|
|
|
|
if (config.collector.storage.kind !== "PersistentVolumeClaim") {
|
|
|
|
|
throw new Error(`${configLabel}.personalWechatIngress.collector.storage.kind must be PersistentVolumeClaim`);
|
|
|
|
|
}
|
|
|
|
|
for (const repoPath of [config.collector.image.context, config.collector.image.dockerfile]) {
|
|
|
|
|
if (!existsSync(resolveRepoPath(repoPath))) throw new Error(`${configLabel}.personalWechatIngress.collector.image path does not exist: ${repoPath}`);
|
|
|
|
|
}
|
|
|
|
|
if (!["Always", "IfNotPresent", "Never"].includes(config.collector.image.pullPolicy)) {
|
|
|
|
|
throw new Error(`${configLabel}.personalWechatIngress.collector.image.pullPolicy must be Always, IfNotPresent, or Never`);
|
|
|
|
|
}
|
|
|
|
|
if (config.pcWechat.requiredVersion !== config.wcfHost.requiredWechatVersion) {
|
|
|
|
|
throw new Error(`${configLabel}.personalWechatIngress pcWechat.requiredVersion and wcfHost.requiredWechatVersion must match`);
|
|
|
|
|
}
|
|
|
|
@@ -701,6 +1542,7 @@ function personalWechatIngressSummary(config: WechatArchiveConfig["personalWecha
|
|
|
|
|
isolation: config.pcWechat.isolation,
|
|
|
|
|
requiredVersion: config.pcWechat.requiredVersion,
|
|
|
|
|
installerAsset: config.pcWechat.installerAsset,
|
|
|
|
|
installMode: config.pcWechat.installMode,
|
|
|
|
|
installRoot: config.pcWechat.installRoot,
|
|
|
|
|
dataRoot: config.pcWechat.dataRoot,
|
|
|
|
|
autoUpdatePolicy: config.pcWechat.autoUpdatePolicy,
|
|
|
|
@@ -724,6 +1566,15 @@ function personalWechatIngressSummary(config: WechatArchiveConfig["personalWecha
|
|
|
|
|
createNamespace: config.collector.createNamespace,
|
|
|
|
|
workload: config.collector.workload,
|
|
|
|
|
storage: config.collector.storage,
|
|
|
|
|
image: {
|
|
|
|
|
reference: collectorImageRef(config),
|
|
|
|
|
repository: config.collector.image.repository,
|
|
|
|
|
tag: config.collector.image.tag,
|
|
|
|
|
pullPolicy: config.collector.image.pullPolicy,
|
|
|
|
|
context: config.collector.image.context,
|
|
|
|
|
dockerfile: config.collector.image.dockerfile,
|
|
|
|
|
wcferryVersion: config.collector.image.wcferryVersion,
|
|
|
|
|
},
|
|
|
|
|
wcfHost: config.collector.wcfHost,
|
|
|
|
|
commandPort: config.collector.commandPort,
|
|
|
|
|
messagePort: config.collector.messagePort,
|
|
|
|
@@ -731,6 +1582,7 @@ function personalWechatIngressSummary(config: WechatArchiveConfig["personalWecha
|
|
|
|
|
queueMode: config.collector.queueMode,
|
|
|
|
|
outboxMode: config.collector.outboxMode,
|
|
|
|
|
archiveCallbackRef: config.collector.archiveCallbackRef,
|
|
|
|
|
secretName: config.collector.secretName,
|
|
|
|
|
readOnly: {
|
|
|
|
|
enabled: config.collector.readOnly.enabled,
|
|
|
|
|
sendCapability: config.collector.readOnly.sendCapability,
|
|
|
|
|