Merge remote-tracking branch 'origin/master' into feat/2891-hwpod-qemu
This commit is contained in:
@@ -0,0 +1,759 @@
|
||||
// SPEC: PJ2026-010603 YAML运维。独立于 Codex pool 的 Sub2API 上游分组。
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { rootPath } from "./config";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { runSshCommandCapture } from "./ssh";
|
||||
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./platform-infra-sub2api-codex/runtime-target";
|
||||
|
||||
const configPath = rootPath("config/platform-infra/sub2api-upstream-groups.yaml");
|
||||
|
||||
type Protocol = "openai-chat";
|
||||
|
||||
interface AccountConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: Protocol;
|
||||
responsesMode: "force_chat_completions";
|
||||
modelPrefix: string;
|
||||
baseUrl: string;
|
||||
modelsPath: string;
|
||||
smokeModel: string | null;
|
||||
sourceRef: string;
|
||||
priority: number;
|
||||
capacity: number;
|
||||
loadFactor: number;
|
||||
}
|
||||
|
||||
interface GroupConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
platform: "openai";
|
||||
consumerApiKeyName: string;
|
||||
consumerApiKeySourceRef: string;
|
||||
accounts: AccountConfig[];
|
||||
}
|
||||
|
||||
interface UpstreamGroupsConfig {
|
||||
defaultTargetId: string;
|
||||
groups: GroupConfig[];
|
||||
}
|
||||
|
||||
interface CliOptions {
|
||||
action: "plan" | "apply" | "status" | "smoke" | "credential-init";
|
||||
targetId: string;
|
||||
groupId: string | null;
|
||||
accountId: string | null;
|
||||
confirm: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
export async function runSub2ApiUpstreamGroupsCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const options = parseOptions(args);
|
||||
const desired = readConfig();
|
||||
const targetId = options.targetId || desired.defaultTargetId;
|
||||
if (options.action === "credential-init") return credentialInit(desired, options);
|
||||
const selected = selectGroups(desired, options.groupId);
|
||||
const local = await localPlan(selected);
|
||||
if (options.action === "plan") return renderResult(planResult(targetId, selected, local), options);
|
||||
if (options.action === "smoke") return renderResult(await smokeGroups(config, targetId, selected, local), options);
|
||||
if (options.action === "apply") {
|
||||
if (!options.confirm || !local.ok) {
|
||||
return renderResult({
|
||||
...planResult(targetId, selected, local),
|
||||
mode: options.confirm ? "blocked-invalid-secret-or-upstream" : "dry-run",
|
||||
mutation: false,
|
||||
next: options.confirm ? "修复缺失 Secret 或上游模型探针后重试。" : confirmCommand(options),
|
||||
}, options);
|
||||
}
|
||||
return renderResult(await applyGroups(config, targetId, selected, local), options);
|
||||
}
|
||||
return renderResult(await statusGroups(config, targetId, selected, local), options);
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): CliOptions {
|
||||
const [actionRaw = "plan", ...rest] = args;
|
||||
if (!["plan", "apply", "status", "smoke", "credential-init"].includes(actionRaw)) {
|
||||
throw new Error("upstream-groups usage: plan|apply|status|smoke|credential-init [--group <id>] [--account <id>] [--target <id>] [--confirm] [--json]");
|
||||
}
|
||||
let targetId = defaultCodexPoolRuntimeTargetId();
|
||||
let groupId: string | null = null;
|
||||
let accountId: string | null = null;
|
||||
let confirm = false;
|
||||
let json = false;
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const arg = rest[index]!;
|
||||
const value = (name: string): string => {
|
||||
const next = rest[index + 1];
|
||||
if (next === undefined || next.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
index += 1;
|
||||
return next;
|
||||
};
|
||||
if (arg === "--confirm") confirm = true;
|
||||
else if (arg === "--json") json = true;
|
||||
else if (arg === "--target") targetId = value("--target");
|
||||
else if (arg.startsWith("--target=")) targetId = arg.slice(9);
|
||||
else if (arg === "--group") groupId = value("--group");
|
||||
else if (arg.startsWith("--group=")) groupId = arg.slice(8);
|
||||
else if (arg === "--account") accountId = value("--account");
|
||||
else if (arg.startsWith("--account=")) accountId = arg.slice(10);
|
||||
else throw new Error(`unsupported upstream-groups option: ${arg}`);
|
||||
}
|
||||
const action = actionRaw as CliOptions["action"];
|
||||
if (action === "credential-init" && accountId === null) throw new Error("credential-init requires --account <id>");
|
||||
if (action !== "credential-init" && accountId !== null) throw new Error("--account only supports credential-init");
|
||||
if (action !== "apply" && action !== "credential-init" && confirm) throw new Error(`${action} does not accept --confirm`);
|
||||
return { action, targetId, groupId, accountId, confirm, json };
|
||||
}
|
||||
|
||||
function readConfig(): UpstreamGroupsConfig {
|
||||
const raw = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
const root = record(raw, configPath);
|
||||
const defaults = record(root.defaults, `${configPath}.defaults`);
|
||||
const defaultTargetId = text(defaults.targetId, `${configPath}.defaults.targetId`);
|
||||
if (!Array.isArray(root.groups) || root.groups.length === 0) throw new Error(`${configPath}.groups must be a non-empty list`);
|
||||
const groups = root.groups.map((entry, groupIndex): GroupConfig => {
|
||||
const group = record(entry, `${configPath}.groups[${groupIndex}]`);
|
||||
const consumer = record(group.consumerApiKey, `${configPath}.groups[${groupIndex}].consumerApiKey`);
|
||||
const platform = text(group.platform, `${configPath}.groups[${groupIndex}].platform`);
|
||||
if (platform !== "openai") throw new Error(`${configPath}.groups[${groupIndex}].platform must be openai`);
|
||||
if (!Array.isArray(group.accounts) || group.accounts.length === 0) throw new Error(`${configPath}.groups[${groupIndex}].accounts must be non-empty`);
|
||||
return {
|
||||
id: identifier(group.id, `${configPath}.groups[${groupIndex}].id`),
|
||||
name: text(group.name, `${configPath}.groups[${groupIndex}].name`),
|
||||
description: text(group.description, `${configPath}.groups[${groupIndex}].description`),
|
||||
platform,
|
||||
consumerApiKeyName: text(consumer.name, `${configPath}.groups[${groupIndex}].consumerApiKey.name`),
|
||||
consumerApiKeySourceRef: absolutePath(consumer.sourceRef, `${configPath}.groups[${groupIndex}].consumerApiKey.sourceRef`),
|
||||
accounts: group.accounts.map((item, accountIndex): AccountConfig => {
|
||||
const account = record(item, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}]`);
|
||||
const credential = record(account.credential, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].credential`);
|
||||
const protocol = text(account.protocol, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].protocol`);
|
||||
if (protocol !== "openai-chat") throw new Error(`${configPath}.groups[${groupIndex}].accounts[${accountIndex}].protocol must be openai-chat`);
|
||||
const responsesMode = text(account.responsesMode, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].responsesMode`);
|
||||
if (responsesMode !== "force_chat_completions") throw new Error(`${configPath}.groups[${groupIndex}].accounts[${accountIndex}].responsesMode must be force_chat_completions`);
|
||||
const baseUrl = new URL(text(account.baseUrl, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].baseUrl`));
|
||||
if (baseUrl.protocol !== "https:") throw new Error("upstream baseUrl must use https");
|
||||
return {
|
||||
id: identifier(account.id, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].id`),
|
||||
name: text(account.name, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].name`),
|
||||
protocol,
|
||||
responsesMode,
|
||||
modelPrefix: identifier(account.modelPrefix, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].modelPrefix`),
|
||||
baseUrl: baseUrl.toString().replace(/\/$/u, ""),
|
||||
modelsPath: pathValue(account.modelsPath, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].modelsPath`),
|
||||
smokeModel: account.smokeModel === undefined ? null : text(account.smokeModel, "smokeModel"),
|
||||
sourceRef: absolutePath(credential.sourceRef, `${configPath}.groups[${groupIndex}].accounts[${accountIndex}].credential.sourceRef`),
|
||||
priority: boundedInteger(account.priority, "priority", 1, 1000),
|
||||
capacity: boundedInteger(account.capacity, "capacity", 1, 100000),
|
||||
loadFactor: boundedInteger(account.loadFactor, "loadFactor", 1, 1000),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
const ids = groups.flatMap((group) => [group.id, ...group.accounts.map((account) => account.id)]);
|
||||
if (new Set(ids).size !== ids.length) throw new Error(`${configPath} group/account ids must be globally unique`);
|
||||
return { defaultTargetId, groups };
|
||||
}
|
||||
|
||||
function credentialInit(config: UpstreamGroupsConfig, options: CliOptions): Record<string, unknown> {
|
||||
const account = config.groups.flatMap((group) => group.accounts).find((item) => item.id === options.accountId);
|
||||
if (account === undefined) throw new Error(`unknown upstream account id: ${options.accountId}`);
|
||||
if (!options.confirm) {
|
||||
return {
|
||||
ok: true,
|
||||
action: "platform-infra-sub2api-upstream-groups-credential-init",
|
||||
mode: "dry-run",
|
||||
account: { id: account.id, name: account.name },
|
||||
secret: secretSummary(account.sourceRef),
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
next: `将 API key 通过标准输入传给:bun scripts/cli.ts platform-infra sub2api upstream-groups credential-init --account ${account.id} --confirm`,
|
||||
};
|
||||
}
|
||||
const value = readFileSync(0, "utf8").trim();
|
||||
if (!/^sk-[A-Za-z0-9_=+/.-]{16,}$/u.test(value)) throw new Error("stdin must contain one supported API key");
|
||||
mkdirSync(dirname(account.sourceRef), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(account.sourceRef, `${value}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(account.sourceRef, 0o600);
|
||||
return {
|
||||
ok: true,
|
||||
action: "platform-infra-sub2api-upstream-groups-credential-init",
|
||||
mode: "confirmed",
|
||||
account: { id: account.id, name: account.name },
|
||||
secret: secretSummary(account.sourceRef),
|
||||
mutation: true,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function localPlan(groups: GroupConfig[]): Promise<Record<string, unknown>> {
|
||||
const accounts = [];
|
||||
for (const group of groups) {
|
||||
for (const account of group.accounts) {
|
||||
const secret = secretSummary(account.sourceRef);
|
||||
const models = secret.present ? await probeModels(account, readSecret(account.sourceRef)) : { ok: false, status: null, models: [], error: "secret-missing" };
|
||||
accounts.push({
|
||||
groupId: group.id,
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
protocol: account.protocol,
|
||||
baseUrl: account.baseUrl,
|
||||
secret,
|
||||
models: { ok: models.ok, status: models.status, count: models.models.length, sample: models.models.slice(0, 8), all: models.models, error: models.error },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: accounts.every((item) => item.secret.present === true && item.models.ok === true), accounts, valuesPrinted: false };
|
||||
}
|
||||
|
||||
async function probeModels(account: AccountConfig, apiKey: string): Promise<{ ok: boolean; status: number | null; models: string[]; error: string | null }> {
|
||||
try {
|
||||
const response = await fetch(`${account.baseUrl}${account.modelsPath}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
const body = await response.json() as unknown;
|
||||
const models = modelIds(body);
|
||||
return { ok: response.ok && models.length > 0, status: response.status, models, error: response.ok ? null : `http-${response.status}` };
|
||||
} catch (error) {
|
||||
return { ok: false, status: null, models: [], error: error instanceof Error ? error.message.slice(0, 160) : "probe-failed" };
|
||||
}
|
||||
}
|
||||
|
||||
function planResult(targetId: string, groups: GroupConfig[], local: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
ok: local.ok === true,
|
||||
action: "platform-infra-sub2api-upstream-groups-plan",
|
||||
mode: "dry-run",
|
||||
targetId,
|
||||
configPath,
|
||||
groups: groups.map(redactGroup),
|
||||
local,
|
||||
isolation: { codexPoolMembership: false, codexSettingsApplied: false },
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
next: `bun scripts/cli.ts platform-infra sub2api upstream-groups apply --target ${targetId} --confirm`,
|
||||
};
|
||||
}
|
||||
|
||||
async function applyGroups(config: UniDeskConfig, targetId: string, groups: GroupConfig[], local: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const target = codexPoolRuntimeTarget(targetId);
|
||||
if (target.runtimeMode !== "host-docker" || target.hostDockerAppPort === null || target.hostDockerEnvPath === null) {
|
||||
throw new Error("upstream-groups apply currently requires a YAML-selected host-docker target");
|
||||
}
|
||||
const payloadGroups = groups.map((group) => {
|
||||
const consumerKey = ensureConsumerKey(group.consumerApiKeySourceRef);
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
platform: group.platform,
|
||||
consumerApiKeyName: group.consumerApiKeyName,
|
||||
consumerApiKey: consumerKey,
|
||||
accounts: group.accounts.map((account) => {
|
||||
const planned = Array.isArray(local.accounts)
|
||||
? local.accounts.filter(isRecord).find((item) => item.id === account.id)
|
||||
: undefined;
|
||||
const modelProbe = planned !== undefined && isRecord(planned.models) ? planned.models : {};
|
||||
const models = Array.isArray(modelProbe.all)
|
||||
? modelProbe.all.filter((model): model is string => typeof model === "string")
|
||||
: [];
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
protocol: account.protocol,
|
||||
responsesMode: account.responsesMode,
|
||||
modelPrefix: account.modelPrefix,
|
||||
baseUrl: account.baseUrl,
|
||||
apiKey: readSecret(account.sourceRef),
|
||||
sourceRef: account.sourceRef,
|
||||
secretFingerprint: fingerprint(readSecret(account.sourceRef)),
|
||||
modelMapping: Object.fromEntries(models.map((model) => [`${account.modelPrefix}/${model.toLowerCase()}`, model])),
|
||||
priority: account.priority,
|
||||
capacity: account.capacity,
|
||||
loadFactor: account.loadFactor,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
const script = remoteApplyScript(target.hostDockerAppPort, target.hostDockerEnvPath, payloadGroups);
|
||||
const result = await runSshCommandCapture(config, target.route, ["sh"], script);
|
||||
if (result.exitCode !== 0) throw new Error(`upstream-groups apply failed: ${result.stderr.slice(-1200)}`);
|
||||
const remote = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
||||
return {
|
||||
ok: remote.ok === true,
|
||||
action: "platform-infra-sub2api-upstream-groups-apply",
|
||||
mode: "confirmed",
|
||||
targetId,
|
||||
configPath,
|
||||
groups: groups.map(redactGroup),
|
||||
local,
|
||||
remote,
|
||||
isolation: { codexPoolMembership: false, codexSettingsApplied: false },
|
||||
mutation: true,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function statusGroups(config: UniDeskConfig, targetId: string, groups: GroupConfig[], local: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const target = codexPoolRuntimeTarget(targetId);
|
||||
if (target.runtimeMode !== "host-docker" || target.hostDockerAppPort === null || target.hostDockerEnvPath === null) {
|
||||
throw new Error("upstream-groups status currently requires a YAML-selected host-docker target");
|
||||
}
|
||||
const script = remoteStatusScript(target.hostDockerAppPort, target.hostDockerEnvPath, groups.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
accountNames: group.accounts.map((account) => account.name),
|
||||
})));
|
||||
const result = await runSshCommandCapture(config, target.route, ["sh"], script);
|
||||
if (result.exitCode !== 0) throw new Error(`upstream-groups status failed: ${result.stderr.slice(-1200)}`);
|
||||
const remote = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
||||
return {
|
||||
ok: local.ok === true && remote.ok === true,
|
||||
action: "platform-infra-sub2api-upstream-groups-status",
|
||||
targetId,
|
||||
configPath,
|
||||
local,
|
||||
remote,
|
||||
isolation: { codexPoolMembership: false, codexSettingsApplied: false },
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function smokeGroups(config: UniDeskConfig, targetId: string, groups: GroupConfig[], local: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const target = codexPoolRuntimeTarget(targetId);
|
||||
if (target.publicBaseUrl === null) throw new Error(`target ${targetId} has no enabled publicBaseUrl`);
|
||||
const directPayload = groups.flatMap((group) => group.accounts.map((account) => ({
|
||||
groupId: group.id,
|
||||
account: account.name,
|
||||
url: `${account.baseUrl}/chat/completions`,
|
||||
apiKey: readSecret(account.sourceRef),
|
||||
model: account.smokeModel,
|
||||
})));
|
||||
const directCapture = await runSshCommandCapture(config, target.route, ["sh"], remoteDirectSmokeScript(directPayload));
|
||||
if (directCapture.exitCode !== 0) throw new Error(`upstream-groups direct smoke failed: ${directCapture.stderr.slice(-1200)}`);
|
||||
const direct = JSON.parse(directCapture.stdout.trim()) as Record<string, unknown>;
|
||||
const results = [];
|
||||
for (const group of groups) {
|
||||
const apiKey = readSecret(group.consumerApiKeySourceRef);
|
||||
for (const account of group.accounts) {
|
||||
if (account.smokeModel === null) {
|
||||
results.push({ groupId: group.id, account: account.name, ok: false, model: null, status: null, error: "smokeModel-not-configured" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${target.publicBaseUrl.replace(/\/+$/u, "")}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: `${account.modelPrefix}/${account.smokeModel.toLowerCase()}`,
|
||||
messages: [{ role: "user", content: "Reply with exactly: OK" }],
|
||||
stream: false,
|
||||
max_tokens: 16,
|
||||
}),
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
});
|
||||
const body = await response.json().catch(() => ({})) as unknown;
|
||||
const root = isRecord(body) ? body : {};
|
||||
const choices = Array.isArray(root.choices) ? root.choices : [];
|
||||
const first = choices.find(isRecord);
|
||||
const message = first !== undefined && isRecord(first.message) ? first.message : {};
|
||||
const content = typeof message.content === "string" ? message.content.replace(/\s+/gu, " ").trim().slice(0, 120) : null;
|
||||
const requestId = response.headers.get("x-request-id") ?? (typeof root.id === "string" ? root.id : null);
|
||||
results.push({
|
||||
groupId: group.id,
|
||||
account: account.name,
|
||||
ok: response.ok && content !== null,
|
||||
model: `${account.modelPrefix}/${account.smokeModel.toLowerCase()}`,
|
||||
status: response.status,
|
||||
requestId,
|
||||
responsePreview: content,
|
||||
error: response.ok ? null : `http-${response.status}`,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
groupId: group.id,
|
||||
account: account.name,
|
||||
ok: false,
|
||||
model: `${account.modelPrefix}/${account.smokeModel.toLowerCase()}`,
|
||||
status: null,
|
||||
requestId: null,
|
||||
responsePreview: null,
|
||||
error: error instanceof Error ? error.message.slice(0, 160) : "smoke-failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: local.ok === true && results.every((item) => item.ok),
|
||||
action: "platform-infra-sub2api-upstream-groups-smoke",
|
||||
targetId,
|
||||
publicBaseUrl: target.publicBaseUrl,
|
||||
local,
|
||||
direct,
|
||||
results,
|
||||
isolation: { codexPoolMembership: false, codexSettingsApplied: false },
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteDirectSmokeScript(accounts: unknown): string {
|
||||
const payload = Buffer.from(JSON.stringify(accounts), "utf8").toString("base64");
|
||||
return `
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import base64, json, subprocess
|
||||
accounts = json.loads(base64.b64decode(${JSON.stringify(payload)}).decode())
|
||||
results = []
|
||||
for account in accounts:
|
||||
if not account.get("model"):
|
||||
results.append({"groupId": account["groupId"], "account": account["account"], "ok": False, "status": None, "model": None, "error": "smokeModel-not-configured"})
|
||||
continue
|
||||
body = json.dumps({"model": account["model"], "messages": [{"role": "user", "content": "Reply with exactly: OK"}], "stream": False, "max_tokens": 16}, separators=(",", ":"))
|
||||
proc = subprocess.run(["curl", "-sS", "--max-time", "90", "-w", "\\n__HTTP__:%{http_code}", "-H", "Authorization: Bearer " + account["apiKey"], "-H", "Content-Type: application/json", "--data-binary", body, account["url"]], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output = proc.stdout.decode("utf-8", errors="replace")
|
||||
marker = "\\n__HTTP__:"
|
||||
content, status = output.rsplit(marker, 1) if marker in output else ("", "000")
|
||||
parsed = {}
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except Exception:
|
||||
pass
|
||||
choices = parsed.get("choices") if isinstance(parsed, dict) else None
|
||||
message = choices[0].get("message") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
||||
preview = message.get("content") if isinstance(message, dict) else None
|
||||
error = parsed.get("error") if isinstance(parsed, dict) else None
|
||||
error_message = error.get("message") if isinstance(error, dict) else None
|
||||
results.append({"groupId": account["groupId"], "account": account["account"], "ok": proc.returncode == 0 and status.startswith("2"), "status": int(status) if status.isdigit() else None, "model": account["model"], "responsePreview": str(preview)[:120] if preview is not None else None, "error": str(error_message)[:160] if error_message is not None else (None if status.startswith("2") else "http-" + status)})
|
||||
print(json.dumps({"ok": all(item["ok"] for item in results), "results": results, "valuesPrinted": False}, ensure_ascii=False))
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function remoteApplyScript(appPort: number, envPath: string, groups: unknown): string {
|
||||
const payload = Buffer.from(JSON.stringify(groups), "utf8").toString("base64");
|
||||
return remotePython(appPort, envPath, `
|
||||
groups = json.loads(base64.b64decode(${JSON.stringify(payload)}).decode())
|
||||
token = login()
|
||||
results = []
|
||||
for desired in groups:
|
||||
listed = items(api("GET", "/api/v1/admin/groups/all?platform=openai", token))
|
||||
current = next((item for item in listed if item.get("name") == desired["name"]), None)
|
||||
group_payload = {"name": desired["name"], "description": desired["description"], "platform": "openai", "rate_multiplier": 1, "is_exclusive": False, "subscription_type": "standard", "allow_messages_dispatch": False, "require_oauth_only": False, "require_privacy_set": False, "rpm_limit": 0}
|
||||
if current is None:
|
||||
current = data(api("POST", "/api/v1/admin/groups", token, group_payload))
|
||||
group_action = "created"
|
||||
else:
|
||||
current = data(api("PUT", "/api/v1/admin/groups/" + str(current["id"]), token, {**group_payload, "status": "active"}))
|
||||
group_action = "updated"
|
||||
group_id = current["id"]
|
||||
account_results = []
|
||||
for account in desired["accounts"]:
|
||||
found = items(api("GET", "/api/v1/admin/accounts?page=1&page_size=50&platform=openai&search=" + quote(account["name"]), token))
|
||||
existing = next((item for item in found if item.get("name") == account["name"]), None)
|
||||
account_payload = {
|
||||
"name": account["name"],
|
||||
"notes": "UniDesk YAML-managed independent upstream; id=" + account["id"] + "; source=" + account["sourceRef"] + "; fingerprint=" + account["secretFingerprint"],
|
||||
"platform": "openai",
|
||||
"type": "apikey",
|
||||
"credentials": {"api_key": account["apiKey"], "base_url": account["baseUrl"], "model_mapping": account["modelMapping"]},
|
||||
"extra": {"unidesk_managed_upstream_group": True, "unidesk_upstream_id": account["id"], "openai_responses_mode": account["responsesMode"]},
|
||||
"concurrency": account["capacity"],
|
||||
"priority": account["priority"],
|
||||
"rate_multiplier": 1,
|
||||
"load_factor": account["loadFactor"],
|
||||
"group_ids": [group_id],
|
||||
"confirm_mixed_channel_risk": True,
|
||||
}
|
||||
if existing and existing.get("id") is not None:
|
||||
account_payload.pop("platform", None)
|
||||
account_payload["status"] = "active"
|
||||
saved = data(api("PUT", "/api/v1/admin/accounts/" + str(existing["id"]), token, account_payload))
|
||||
action = "updated"
|
||||
else:
|
||||
saved = data(api("POST", "/api/v1/admin/accounts", token, account_payload))
|
||||
action = "created"
|
||||
saved = data(api("POST", "/api/v1/admin/accounts/" + str(saved["id"]) + "/schedulable", token, {"schedulable": True}))
|
||||
account_results.append({"id": saved.get("id"), "name": account["name"], "action": action, "groupId": group_id})
|
||||
keys = items(api("GET", "/api/v1/keys?page=1&page_size=200", token))
|
||||
key = next((item for item in keys if item.get("name") == desired["consumerApiKeyName"]), None)
|
||||
if key is None:
|
||||
key = data(api("POST", "/api/v1/keys", token, {"name": desired["consumerApiKeyName"], "group_id": group_id, "custom_key": desired["consumerApiKey"], "quota": 0, "rate_limit_5h": 0, "rate_limit_1d": 0, "rate_limit_7d": 0}))
|
||||
key_action = "created"
|
||||
elif key.get("id") is not None and key.get("group_id") != group_id:
|
||||
key = data(api("PUT", "/api/v1/keys/" + str(key["id"]), token, {"name": desired["consumerApiKeyName"], "group_id": group_id}))
|
||||
key_action = "updated"
|
||||
else:
|
||||
key_action = "kept"
|
||||
results.append({"groupId": group_id, "groupName": desired["name"], "groupAction": group_action, "accounts": account_results, "apiKey": {"id": key.get("id"), "name": desired["consumerApiKeyName"], "groupId": group_id, "action": key_action}})
|
||||
print(json.dumps({"ok": True, "groups": results, "valuesPrinted": False}, ensure_ascii=False))
|
||||
`);
|
||||
}
|
||||
|
||||
function remoteStatusScript(appPort: number, envPath: string, groups: unknown): string {
|
||||
const payload = Buffer.from(JSON.stringify(groups), "utf8").toString("base64");
|
||||
return remotePython(appPort, envPath, `
|
||||
desired = json.loads(base64.b64decode(${JSON.stringify(payload)}).decode())
|
||||
token = login()
|
||||
all_groups = items(api("GET", "/api/v1/admin/groups/all?platform=openai", token))
|
||||
results = []
|
||||
for item in desired:
|
||||
group = next((candidate for candidate in all_groups if candidate.get("name") == item["name"]), None)
|
||||
if group is None:
|
||||
results.append({"id": item["id"], "name": item["name"], "present": False, "accounts": []})
|
||||
continue
|
||||
raw_accounts = items(api("GET", "/api/v1/admin/accounts?group_id=" + str(group["id"]) + "&page=1&page_size=500&platform=openai", token))
|
||||
expected_names = set(item["accountNames"])
|
||||
accounts = [account for account in raw_accounts if account.get("name") in expected_names]
|
||||
results.append({"id": item["id"], "name": item["name"], "present": True, "groupId": group.get("id"), "expectedAccountCount": len(expected_names), "filteredAccountCount": len(accounts), "rawReturnedCount": len(raw_accounts), "nativeFilterOverbroad": len(raw_accounts) > len(accounts), "accounts": [{"id": account.get("id"), "name": account.get("name"), "status": account.get("status"), "schedulable": account.get("schedulable"), "priority": account.get("priority")} for account in accounts]})
|
||||
print(json.dumps({"ok": all(item.get("present") and item.get("filteredAccountCount") == item.get("expectedAccountCount") for item in results), "groups": results, "valuesPrinted": False}, ensure_ascii=False))
|
||||
`);
|
||||
}
|
||||
|
||||
function remotePython(appPort: number, envPath: string, body: string): string {
|
||||
return `
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import base64, json, subprocess
|
||||
from urllib.parse import quote
|
||||
|
||||
APP_PORT = ${JSON.stringify(appPort)}
|
||||
ENV_PATH = ${JSON.stringify(envPath)}
|
||||
|
||||
def env_values():
|
||||
try:
|
||||
raw = open(ENV_PATH, "r", encoding="utf-8").read()
|
||||
except PermissionError:
|
||||
proc = subprocess.run(["sudo", "-n", "cat", ENV_PATH], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("cannot read host env source")
|
||||
raw = proc.stdout.decode()
|
||||
result = {}
|
||||
for line in raw.splitlines():
|
||||
text = line.strip()
|
||||
if not text or text.startswith("#") or "=" not in text:
|
||||
continue
|
||||
key, value = text.split("=", 1)
|
||||
value = value.strip()
|
||||
if len(value) > 1 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1]
|
||||
result[key.strip()] = value
|
||||
return result
|
||||
|
||||
def api(method, path, token=None, payload=None):
|
||||
command = ["curl", "-sS", "-w", "\\n__HTTP__:%{http_code}", "-X", method, "-H", "Content-Type: application/json"]
|
||||
if token:
|
||||
command += ["-H", "Authorization: Bearer " + token]
|
||||
if payload is not None:
|
||||
command += ["--data-binary", json.dumps(payload, separators=(",", ":"))]
|
||||
command += ["http://127.0.0.1:" + str(APP_PORT) + path]
|
||||
proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output = proc.stdout.decode("utf-8", errors="replace")
|
||||
marker = "\\n__HTTP__:"
|
||||
content, status = output.rsplit(marker, 1)
|
||||
parsed = json.loads(content) if content.strip() else {}
|
||||
code = parsed.get("code") if isinstance(parsed, dict) else None
|
||||
if proc.returncode != 0 or int(status) >= 400 or (code is not None and code != 0):
|
||||
message = parsed.get("message") if isinstance(parsed, dict) else "request failed"
|
||||
raise RuntimeError("Sub2API request failed: http=" + status + " message=" + str(message)[:300])
|
||||
return parsed
|
||||
|
||||
def data(value):
|
||||
return value.get("data") if isinstance(value, dict) and "data" in value else value
|
||||
|
||||
def items(value):
|
||||
value = data(value)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
if isinstance(value.get("items"), list):
|
||||
return value["items"]
|
||||
for key in ("groups", "accounts", "api_keys", "keys"):
|
||||
if isinstance(value.get(key), list):
|
||||
return value[key]
|
||||
return []
|
||||
|
||||
def login():
|
||||
env = env_values()
|
||||
email = env.get("ADMIN_EMAIL") or "admin@sub2api.platform-infra.local"
|
||||
password = env.get("ADMIN_PASSWORD")
|
||||
if not password:
|
||||
raise RuntimeError("ADMIN_PASSWORD missing")
|
||||
value = data(api("POST", "/api/v1/auth/login", payload={"email": email, "password": password}))
|
||||
token = value.get("access_token") or value.get("token")
|
||||
if not token:
|
||||
raise RuntimeError("admin login token missing")
|
||||
return token
|
||||
|
||||
${body}
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function selectGroups(config: UpstreamGroupsConfig, groupId: string | null): GroupConfig[] {
|
||||
if (groupId === null) return config.groups;
|
||||
const selected = config.groups.find((group) => group.id === groupId);
|
||||
if (selected === undefined) throw new Error(`unknown upstream group id: ${groupId}`);
|
||||
return [selected];
|
||||
}
|
||||
|
||||
function redactGroup(group: GroupConfig): Record<string, unknown> {
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
platform: group.platform,
|
||||
consumerApiKey: { name: group.consumerApiKeyName, ...secretSummary(group.consumerApiKeySourceRef) },
|
||||
accounts: group.accounts.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
protocol: account.protocol,
|
||||
responsesMode: account.responsesMode,
|
||||
modelPrefix: account.modelPrefix,
|
||||
baseUrl: account.baseUrl,
|
||||
priority: account.priority,
|
||||
capacity: account.capacity,
|
||||
loadFactor: account.loadFactor,
|
||||
credential: secretSummary(account.sourceRef),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renderResult(result: Record<string, unknown>, options: CliOptions): Record<string, unknown> | RenderedCliResult {
|
||||
if (options.json) return result;
|
||||
const local = isRecord(result.local) ? result.local : {};
|
||||
const accounts = Array.isArray(local.accounts) ? local.accounts.filter(isRecord) : [];
|
||||
const rows = accounts.map((account) => {
|
||||
const secret = isRecord(account.secret) ? account.secret : {};
|
||||
const models = isRecord(account.models) ? account.models : {};
|
||||
return [
|
||||
String(account.name ?? "-"),
|
||||
String(account.protocol ?? "-"),
|
||||
secret.present === true ? "present" : "missing",
|
||||
String(secret.fingerprint ?? "-"),
|
||||
models.ok === true ? "ok" : "failed",
|
||||
String(models.count ?? 0),
|
||||
Array.isArray(models.sample) ? models.sample.slice(0, 3).join(",") : "-",
|
||||
];
|
||||
});
|
||||
const remote = isRecord(result.remote) ? result.remote : {};
|
||||
const direct = isRecord(result.direct) ? result.direct : {};
|
||||
const smokeResults = Array.isArray(result.results) ? result.results.filter(isRecord) : [];
|
||||
const lines = [
|
||||
`SUB2API UPSTREAM GROUPS ${String(result.mode ?? options.action).toUpperCase()} ok=${result.ok === true}`,
|
||||
`TARGET ${String(result.targetId ?? options.targetId)} CONFIG ${configPath}`,
|
||||
...table(["ACCOUNT", "PROTOCOL", "SECRET", "FINGERPRINT", "MODELS", "COUNT", "SAMPLE"], rows),
|
||||
`ISOLATION codexPoolMembership=false codexSettingsApplied=false valuesPrinted=false`,
|
||||
];
|
||||
if (Object.keys(remote).length > 0) lines.push(`REMOTE groups=${Array.isArray(remote.groups) ? remote.groups.length : 0} ok=${remote.ok === true}`);
|
||||
if (Object.keys(direct).length > 0) lines.push(`DIRECT ok=${direct.ok === true}`);
|
||||
if (smokeResults.length > 0) {
|
||||
lines.push(...table(
|
||||
["SMOKE_ACCOUNT", "MODEL", "STATUS", "REQUEST_ID", "RESULT"],
|
||||
smokeResults.map((item) => [
|
||||
String(item.account ?? "-"),
|
||||
String(item.model ?? "-"),
|
||||
String(item.status ?? "-"),
|
||||
String(item.requestId ?? "-"),
|
||||
item.ok === true ? "ok" : String(item.error ?? "failed"),
|
||||
]),
|
||||
));
|
||||
}
|
||||
if (typeof result.next === "string") lines.push(`NEXT ${result.next}`);
|
||||
return {
|
||||
ok: result.ok === true,
|
||||
command: `platform-infra sub2api upstream-groups ${options.action}`,
|
||||
renderedText: `${lines.join("\n")}\n`,
|
||||
contentType: "text/plain",
|
||||
projection: result,
|
||||
};
|
||||
}
|
||||
|
||||
function confirmCommand(options: CliOptions): string {
|
||||
return `bun scripts/cli.ts platform-infra sub2api upstream-groups apply --target ${options.targetId}${options.groupId ? ` --group ${options.groupId}` : ""} --confirm`;
|
||||
}
|
||||
|
||||
function ensureConsumerKey(path: string): string {
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(path, `sk-unidesk-upstream-${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
return readSecret(path);
|
||||
}
|
||||
|
||||
function readSecret(path: string): string {
|
||||
const value = readFileSync(path, "utf8").trim();
|
||||
if (!value) throw new Error(`Secret source is empty: ${path}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function secretSummary(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return { sourceRef: path, present: false, bytes: 0, fingerprint: null, valuesPrinted: false };
|
||||
const stat = statSync(path);
|
||||
const value = readSecret(path);
|
||||
return { sourceRef: path, present: true, bytes: stat.size, mode: (stat.mode & 0o777).toString(8).padStart(4, "0"), fingerprint: fingerprint(value), valuesPrinted: false };
|
||||
}
|
||||
|
||||
function fingerprint(value: string): string {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function modelIds(value: unknown): string[] {
|
||||
const root = isRecord(value) ? value : {};
|
||||
const data = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : [];
|
||||
return [...new Set(data.map((item) => isRecord(item) ? item.id ?? item.name : null).filter((item): item is string => typeof item === "string" && item.length > 0))].sort();
|
||||
}
|
||||
|
||||
function record(value: unknown, key: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error(`${key} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(value: unknown, key: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${key} must be a non-empty string`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function identifier(value: unknown, key: string): string {
|
||||
const result = text(value, key);
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,62}$/u.test(result)) throw new Error(`${key} must be a lowercase identifier`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function absolutePath(value: unknown, key: string): string {
|
||||
const result = text(value, key);
|
||||
if (!result.startsWith("/") || /[\r\n]/u.test(result)) throw new Error(`${key} must be an absolute path`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function pathValue(value: unknown, key: string): string {
|
||||
const result = text(value, key);
|
||||
if (!result.startsWith("/") || result.includes("..")) throw new Error(`${key} must be an absolute URL path`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, key: string, min: number, max: number): number {
|
||||
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) throw new Error(`${key} must be an integer from ${min} to ${max}`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
@@ -147,6 +147,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
|
||||
const { runCodexPoolCommand } = await import("../platform-infra-sub2api-codex");
|
||||
return await runCodexPoolCommand(config, args.slice(2));
|
||||
}
|
||||
if (action === "upstream-groups") {
|
||||
const { runSub2ApiUpstreamGroupsCommand } = await import("../platform-infra-sub2api-upstream-groups");
|
||||
return await runSub2ApiUpstreamGroupsCommand(config, args.slice(2));
|
||||
}
|
||||
return unsupported(args);
|
||||
}
|
||||
|
||||
|
||||
+68
-10
@@ -8,6 +8,7 @@ import { startJob } from "./jobs";
|
||||
import type { SshCaptureResult } from "./ssh";
|
||||
import { capture, fingerprintValues, parseJsonOutput } from "./platform-infra-public-service";
|
||||
import {
|
||||
captureLocal,
|
||||
yamlBooleanField,
|
||||
yamlIntegerArrayField,
|
||||
yamlIntegerField,
|
||||
@@ -70,9 +71,23 @@ interface RawSourceFileConfig {
|
||||
|
||||
type SourceFileConfig = EnvSourceFileConfig | RawSourceFileConfig;
|
||||
|
||||
type DistributionTargetExecution = {
|
||||
executionPlane: "local-k3s";
|
||||
route: null;
|
||||
kubeconfig: string;
|
||||
timeoutSeconds: number;
|
||||
configPath: string;
|
||||
} | {
|
||||
executionPlane: "route";
|
||||
route: string;
|
||||
kubeconfig: null;
|
||||
timeoutSeconds: number;
|
||||
configPath: string;
|
||||
};
|
||||
|
||||
interface DistributionTarget {
|
||||
id: string;
|
||||
route: string;
|
||||
execution: DistributionTargetExecution;
|
||||
namespace: string;
|
||||
scope: string;
|
||||
enabled: boolean;
|
||||
@@ -235,8 +250,9 @@ function plan(options: SecretsOptions): Record<string, unknown> {
|
||||
action: "secrets-plan",
|
||||
mutation: false,
|
||||
config: configSummary(distribution, options),
|
||||
localSources: sourceSummary(sources),
|
||||
desiredSecrets: desired.map(desiredSecretSummary),
|
||||
...(options.full
|
||||
? { localSources: sourceSummary(sources), desiredSecrets: desired.map(desiredSecretSummary) }
|
||||
: { localSources: compactSourceSummary(sources), desiredSecrets: desired.map(compactDesiredSecretSummary) }),
|
||||
policy: {
|
||||
sourceAuthority: "local YAML-declared managed and external sourceRef files",
|
||||
runtimeReverseEngineering: false,
|
||||
@@ -304,8 +320,9 @@ async function status(config: UniDeskConfig, options: SecretsOptions): Promise<R
|
||||
action: "secrets-status",
|
||||
mutation: false,
|
||||
config: configSummary(distribution, options),
|
||||
localSources: sourceSummary(sources),
|
||||
desiredSecrets: desired.map(desiredSecretSummary),
|
||||
...(options.full
|
||||
? { localSources: sourceSummary(sources), desiredSecrets: desired.map(desiredSecretSummary) }
|
||||
: { localSources: compactSourceSummary(sources), desiredSecrets: desired.map(compactDesiredSecretSummary) }),
|
||||
targets: perTarget,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
@@ -393,6 +410,30 @@ function parseExternalSourceFile(record: Record<string, unknown>, path: string):
|
||||
}
|
||||
|
||||
function parseTarget(record: Record<string, unknown>, path: string): DistributionTarget {
|
||||
const executionPath = `${path}.execution`;
|
||||
const execution = objectField(record, "execution", path);
|
||||
const executionPlane = stringField(execution, "executionPlane", executionPath);
|
||||
if (executionPlane !== "local-k3s" && executionPlane !== "route") {
|
||||
throw new Error(`${executionPath}.executionPlane must be local-k3s or route`);
|
||||
}
|
||||
const executionTimeoutSeconds = integerField(execution, "timeoutSeconds", executionPath);
|
||||
if (executionTimeoutSeconds <= 0) throw new Error(`${executionPath}.timeoutSeconds must be positive`);
|
||||
let targetExecution: DistributionTargetExecution;
|
||||
if (executionPlane === "local-k3s") {
|
||||
if (execution.route !== undefined) throw new Error(`${executionPath}.route is only valid for executionPlane=route`);
|
||||
const kubeconfig = stringField(execution, "kubeconfig", executionPath);
|
||||
if (!isAbsolute(kubeconfig)) throw new Error(`${executionPath}.kubeconfig must be an absolute path`);
|
||||
targetExecution = { executionPlane, route: null, kubeconfig, timeoutSeconds: executionTimeoutSeconds, configPath: executionPath };
|
||||
} else {
|
||||
if (execution.kubeconfig !== undefined) throw new Error(`${executionPath}.kubeconfig is only valid for executionPlane=local-k3s`);
|
||||
targetExecution = {
|
||||
executionPlane,
|
||||
route: stringField(execution, "route", executionPath),
|
||||
kubeconfig: null,
|
||||
timeoutSeconds: executionTimeoutSeconds,
|
||||
configPath: executionPath,
|
||||
};
|
||||
}
|
||||
const rollout = record.consumerRollout === undefined ? null : objectField(record, "consumerRollout", path);
|
||||
const deployments = rollout === null
|
||||
? []
|
||||
@@ -404,7 +445,7 @@ function parseTarget(record: Record<string, unknown>, path: string): Distributio
|
||||
}
|
||||
return {
|
||||
id: simpleId(stringField(record, "id", path), `${path}.id`),
|
||||
route: stringField(record, "route", path),
|
||||
execution: targetExecution,
|
||||
namespace: kubernetesNameField(record, "namespace", path),
|
||||
scope: simpleId(stringField(record, "scope", path), `${path}.scope`),
|
||||
enabled: booleanField(record, "enabled", path),
|
||||
@@ -627,7 +668,7 @@ function selectedSourceRefs(config: SecretDistributionConfig, options: SecretsOp
|
||||
async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise<Record<string, unknown>> {
|
||||
if (secrets.length === 0) return { ok: true, target: targetSummary(target), mode: "skipped-no-secrets" };
|
||||
const yaml = renderSecretManifest(target, secrets);
|
||||
const result = await capture(config, target.route, ["sh"], applySecretScript(target, secrets, yaml));
|
||||
const result = await captureTarget(config, target, applySecretScript(target, secrets, yaml));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const applied = result.exitCode === 0 && boolField(parsed, "ok", false);
|
||||
const consumerRolloutStatus = applied && target.consumerRollout !== null
|
||||
@@ -646,7 +687,7 @@ async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTar
|
||||
}
|
||||
|
||||
async function statusTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise<Record<string, unknown>> {
|
||||
const result = await capture(config, target.route, ["sh"], statusSecretScript(target, secrets));
|
||||
const result = await captureTarget(config, target, statusSecretScript(target, secrets));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
||||
@@ -755,7 +796,7 @@ async function waitForConsumerRollout(config: UniDeskConfig, target: Distributio
|
||||
let lastRemote: Record<string, unknown> | null = null;
|
||||
while (true) {
|
||||
attempts += 1;
|
||||
const result = await capture(config, target.route, ["sh"], consumerRolloutStatusScript(target));
|
||||
const result = await captureTarget(config, target, consumerRolloutStatusScript(target));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
lastSummary = parsed;
|
||||
lastRemote = secretCaptureSummary(result);
|
||||
@@ -977,7 +1018,24 @@ function remoteSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function targetSummary(target: DistributionTarget): Record<string, unknown> {
|
||||
return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope, consumerRollout: target.consumerRollout };
|
||||
return {
|
||||
id: target.id,
|
||||
execution: target.execution,
|
||||
namespace: target.namespace,
|
||||
scope: target.scope,
|
||||
consumerRollout: target.consumerRollout,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureTarget(config: UniDeskConfig, target: DistributionTarget, script: string): Promise<SshCaptureResult> {
|
||||
const timeoutMs = target.execution.timeoutSeconds * 1000;
|
||||
if (target.execution.executionPlane === "local-k3s") {
|
||||
return await captureLocal(["sh"], script, {
|
||||
runtimeTimeoutMs: timeoutMs,
|
||||
env: { ...process.env, KUBECONFIG: target.execution.kubeconfig },
|
||||
});
|
||||
}
|
||||
return await capture(config, target.execution.route, ["sh"], script, { runtimeTimeoutMs: timeoutMs });
|
||||
}
|
||||
|
||||
function readOptionValue(args: string[], index: number, option: string): string {
|
||||
|
||||
Reference in New Issue
Block a user