Merge pull request #2017 from pikasTech/feat/pikaoa-platform
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

PikaOA:声明企业平台 YAML-first 自动交付基线
This commit is contained in:
Lyon
2026-07-14 18:02:26 +08:00
committed by GitHub
46 changed files with 3010 additions and 69 deletions
+413 -10
View File
@@ -12,6 +12,7 @@ import { compactText, fingerprintEnvValues as fingerprintValues, parseEnvFile, p
const defaultConfigPath = "config/platform-db/postgres-pk01.yaml";
const managedHbaStart = "# BEGIN unidesk managed pk01-platform-postgres";
const managedHbaEnd = "# END unidesk managed pk01-platform-postgres";
const compactActionablePreviewLimit = 4;
interface PlatformDbOptions {
configPath: string;
@@ -302,10 +303,10 @@ export function platformDbHelp(): unknown {
command: "platform-db postgres plan|status|apply|export-secrets",
output: "json",
usage: [
`bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath}`,
`bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath} [--full|--raw]`,
`bun scripts/cli.ts platform-db postgres status --config ${defaultConfigPath} [--full|--raw]`,
`bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run`,
`bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm`,
`bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run [--full|--raw]`,
`bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm [--full|--raw]`,
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --dry-run`,
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm`,
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm --wait`,
@@ -318,6 +319,7 @@ export function platformDbHelp(): unknown {
configTruth: defaultConfigPath,
defaultMutation: false,
exportSecrets: "export-secrets only materializes YAML-declared local Secret source/export files; it does not touch the remote PostgreSQL service.",
disclosure: "plan, status and export-secrets use bounded summaries by default; --full and --raw explicitly disclose the complete structured result without printing Secret values.",
apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote PK01 job instead of keeping one long SSH session open.",
monitorReset: "monitor reset is restricted by owning YAML to the dedicated web_probe_monitor database and requires --confirm; it cannot accept a database name or arbitrary SQL.",
redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.",
@@ -439,14 +441,15 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise<
const remote = await remoteFacts(config, pg, null);
const facts = remote.parsed;
const checks = facts === null ? [] : desiredChecks(pg, facts, secrets);
return {
const desired = desiredSummary(pg, secrets, facts);
const result = {
ok: remote.capture.exitCode === 0 && facts !== null && secrets.ok,
action: "platform-db-postgres-plan",
mutation: false,
config: configSummary(pg),
secrets: secretSummary(secrets),
remoteFacts: facts,
desired: desiredSummary(pg, secrets, facts),
desired,
checks,
next: {
apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`,
@@ -456,6 +459,8 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise<
remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }),
...(options.raw ? { raw: remote.capture } : {}),
};
if (options.full) return result;
return compactPlan(pg, secrets, facts, checks, remote.capture, result.ok);
}
async function status(config: UniDeskConfig, options: PlatformDbOptions): Promise<Record<string, unknown>> {
@@ -485,7 +490,8 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis
...(secrets.ok ? [] : ["secrets-unhealthy"]),
...(endpointHealthy ? [] : [controllerConnection.attempted ? "connection-host-probe-failed" : "connection-host-probe-unavailable"]),
];
return {
const exportTargets = inspectExportTargets(pg);
const result = {
ok: remote.capture.exitCode === 0 && facts !== null && deploymentHealthy && secrets.ok,
action: "platform-db-postgres-status",
mutation: false,
@@ -522,9 +528,12 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis
},
remoteFacts: facts,
secrets: secretSummary(secrets),
exports: exportTargets,
remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }),
...(options.raw ? { raw: remote.capture } : {}),
};
if (options.full) return result;
return compactStatus(pg, secrets, exportTargets, facts, controllerConnection, remote.capture, result.summary, result.ok);
}
async function apply(config: UniDeskConfig, options: PlatformDbOptions): Promise<Record<string, unknown>> {
@@ -600,7 +609,7 @@ async function exportSecrets(options: PlatformDbOptions): Promise<Record<string,
const pg = readPostgresHostConfig(options.configPath);
if (!options.confirm || options.dryRun) {
const localState = buildLocalSecretState(pg, false);
return {
const result = {
ok: localState.inspection.ok,
action: "platform-db-postgres-export-secrets",
mode: "dry-run",
@@ -613,9 +622,11 @@ async function exportSecrets(options: PlatformDbOptions): Promise<Record<string,
},
valuesPrinted: false,
};
if (options.full) return result;
return compactExportSecrets(pg, localState, "dry-run", false, result.ok);
}
const localState = ensureLocalSecretState(pg);
return {
const result = {
ok: true,
action: "platform-db-postgres-export-secrets",
mode: "confirmed",
@@ -628,6 +639,8 @@ async function exportSecrets(options: PlatformDbOptions): Promise<Record<string,
},
valuesPrinted: false,
};
if (options.full) return result;
return compactExportSecrets(pg, localState, "confirmed", true, result.ok);
}
function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
@@ -1071,6 +1084,360 @@ function configSummary(pg: PostgresHostConfig): Record<string, unknown> {
};
}
function compactPlan(
pg: PostgresHostConfig,
secrets: SecretInspection,
facts: RemoteFacts | null,
checks: Array<Record<string, unknown>>,
capture: SshCaptureResult,
ok: boolean,
): Record<string, unknown> {
const observedRoles = facts?.postgres.roles ?? [];
const observedDatabases = facts?.postgres.databases ?? [];
return {
ok,
action: "platform-db-postgres-plan",
mutation: false,
target: {
node: pg.node.id,
route: pg.node.route,
mode: pg.node.mode,
},
config: {
path: pg.configPath,
id: pg.metadata.id,
pgVersion: pg.postgres.package.version,
connectionHost: pg.postgres.network.connectionHost,
publicDns: pg.postgres.network.publicDns,
port: pg.postgres.network.port,
transport: pg.postgres.network.transport,
sslmode: pg.postgres.network.sslmode,
},
roles: pg.objects.roles.map((role) => ({
name: role.name,
exists: observedRoles.find((item) => item.name === role.name)?.exists ?? null,
action: observedRoles.some((item) => item.name === role.name && item.exists) ? "none" : "create-or-update",
})),
databases: pg.objects.databases.map((database) => ({
name: database.name,
owner: database.owner,
exists: observedDatabases.find((item) => item.name === database.name)?.exists ?? null,
action: observedDatabases.some((item) => item.name === database.name && item.exists) ? "none" : "create",
})),
secrets: {
ok: secrets.ok,
root: secrets.root,
entries: secrets.entries.map((entry) => ({
sourceRef: entry.sourceRef,
missingKeys: entry.missingKeys,
action: entry.action,
})),
valuesPrinted: false,
},
exports: pg.exports.connectionStrings.map((item) => ({
name: item.name,
sourceRef: item.sourceSecretRef,
targetRef: item.writeToSecretSource.sourceRef,
key: item.writeToSecretSource.key,
consumerScopes: item.consumers.map((consumer) => consumer.scope),
})),
checks: {
ok: checks.every((check) => check.ok === true),
passed: checks.filter((check) => check.ok === true).length,
total: checks.length,
items: checks.map((check) => ({
name: check.name,
ok: check.ok,
...(check.ok === true ? {} : { detail: compactText(JSON.stringify(check)).slice(0, 480) }),
})),
},
remoteFacts: compactRemoteFacts(facts),
remote: compactPlanCapture(capture),
next: {
apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`,
status: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath}`,
full: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath} --full`,
raw: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath} --raw`,
issues: pg.metadata.relatedIssues.map((issue) => `https://github.com/pikasTech/unidesk/issues/${issue}`),
},
disclosure: {
compact: true,
omitted: ["package", "host policy", "TLS paths", "backup", "full remote facts"],
fullAvailable: true,
rawAvailable: true,
},
valuesPrinted: false,
};
}
function compactStatus(
pg: PostgresHostConfig,
secrets: SecretInspection,
exports: Array<Record<string, unknown>>,
facts: RemoteFacts | null,
controllerConnection: ControllerConnectionProbe,
capture: SshCaptureResult,
summary: Record<string, unknown> | null,
ok: boolean,
): Record<string, unknown> {
const blockers = summary === null
? ["remote-facts-unavailable"]
: Array.isArray(summary.cutoverBlockers)
? summary.cutoverBlockers
: [];
const roles = pg.objects.roles.map((role) => ({
name: role.name,
exists: facts?.postgres.roles.find((item) => item.name === role.name)?.exists ?? null,
}));
const databases = pg.objects.databases.map((database) => ({
name: database.name,
owner: database.owner,
exists: facts?.postgres.databases.find((item) => item.name === database.name)?.exists ?? null,
}));
const appConnections = facts?.postgres.appConnections ?? [];
return {
ok,
action: "platform-db-postgres-status",
mutation: false,
failure: ok ? null : {
type: capture.exitCode !== 0 || facts === null
? "remote-facts-unavailable"
: secrets.ok
? "deployment-unhealthy"
: "secrets-unhealthy",
blockers,
},
target: {
node: pg.node.id,
route: pg.node.route,
mode: pg.node.mode,
},
config: {
path: pg.configPath,
id: pg.metadata.id,
pgVersion: pg.postgres.package.version,
connectionHost: pg.postgres.network.connectionHost,
port: pg.postgres.network.port,
sslmode: pg.postgres.network.sslmode,
},
summary: summary === null ? null : {
healthy: summary.healthy,
deploymentHealthy: summary.deploymentHealthy,
cutoverReady: summary.cutoverReady,
cutoverBlockers: summary.cutoverBlockers,
packageInstalled: summary.packageInstalled,
serviceActive: summary.serviceActive,
serviceEnabled: summary.serviceEnabled,
sslOn: summary.sslOn,
port5432Listening: summary.port5432Listening,
dnsResolves: summary.dnsResolves,
dnsDisposition: summary.dnsDisposition,
secretsOk: summary.secretsOk,
connectionHostProbe: {
attempted: controllerConnection.attempted,
ok: controllerConnection.ok,
ssl: controllerConnection.ssl,
user: controllerConnection.user,
database: controllerConnection.database,
host: controllerConnection.host,
port: controllerConnection.port,
error: controllerConnection.error,
},
},
roles: compactActionableSummary(roles, {
passed: (item) => item.exists === true,
missing: (item) => item.exists === false,
actionable: (item) => item.exists !== true,
project: (item) => item,
}),
databases: compactActionableSummary(databases, {
passed: (item) => item.exists === true,
missing: (item) => item.exists === false,
actionable: (item) => item.exists !== true,
project: (item) => item,
}),
appConnections: compactActionableSummary(appConnections, {
passed: (item) => item.ok === true && item.ssl === true,
missing: (item) => item.ok !== true || item.ssl !== true,
actionable: (item) => item.ok !== true || item.ssl !== true,
project: (item) => ({ user: item.user, database: item.database, ok: item.ok, ssl: item.ssl, error: item.error }),
}),
secrets: compactSecretInspection(secrets),
exports: compactActionableSummary(exports, {
passed: (item) => item.present === true,
missing: (item) => item.present !== true,
actionable: (item) => item.present !== true,
project: (item) => ({
targetRef: item.targetRef,
key: item.key,
exists: item.exists,
present: item.present,
fingerprint: item.fingerprint,
}),
}),
remote: compactPlanCapture(capture),
next: {
plan: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath}`,
full: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath} --full`,
raw: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath} --raw`,
},
disclosure: {
compact: true,
omitted: ["host facts", "package repository", "TLS paths", "raw remote capture"],
fullAvailable: true,
rawAvailable: true,
},
valuesPrinted: false,
};
}
function compactExportSecrets(
pg: PostgresHostConfig,
localState: { inspection: SecretInspection; summary: Record<string, unknown>; exports: Array<Record<string, unknown>> },
mode: "dry-run" | "confirmed",
mutation: boolean,
ok: boolean,
): Record<string, unknown> {
const suffix = mode === "dry-run" ? " --dry-run" : " --confirm";
const exports = localState.exports.map((item) => {
const before = asRecord(item.before, "export before");
const after = asRecord(item.after, "export after");
return {
name: item.name,
sourceRef: item.sourceRef,
targetRef: item.targetRef,
key: item.key,
before,
after,
action: item.action,
desiredFingerprint: item.desiredFingerprint,
};
});
return {
ok,
action: "platform-db-postgres-export-secrets",
mode,
mutation,
failure: ok ? null : {
type: "secrets-unhealthy",
blockers: localState.inspection.entries.filter((entry) => entry.missingKeys.length > 0).map((entry) => entry.sourceRef),
},
config: {
path: pg.configPath,
id: pg.metadata.id,
secretRoot: localState.inspection.root,
},
secrets: compactSecretInspection(localState.inspection),
exports: compactActionableSummary(exports, {
passed: (item) => item.action === "none" && item.after.present === true,
missing: (item) => item.after.present !== true,
actionable: (item) => item.action !== "none" || item.after.present !== true,
project: (item) => item,
}),
next: {
...(mode === "dry-run" ? { confirm: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath} --confirm` } : {}),
full: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --full`,
raw: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --raw`,
syncConsumers: "run the consumer-specific secret sync command after confirmed export",
},
disclosure: {
compact: true,
omitted: ["source paths", "required key lists", "consumer Secret details"],
fullAvailable: true,
rawAvailable: true,
},
valuesPrinted: false,
};
}
function compactSecretInspection(secrets: SecretInspection): Record<string, unknown> {
const entries = secrets.entries.map((entry) => ({
sourceRef: entry.sourceRef,
exists: entry.exists,
presentKeys: entry.presentKeys,
missingKeys: entry.missingKeys,
action: entry.action,
fingerprint: entry.fingerprint,
}));
return {
ok: secrets.ok,
root: secrets.root,
...compactActionableSummary(entries, {
passed: (item) => item.exists && item.missingKeys.length === 0 && item.action === "none",
missing: (item) => !item.exists || item.missingKeys.length > 0,
actionable: (item) => item.action !== "none" || !item.exists || item.missingKeys.length > 0,
project: (item) => item,
}),
valuesPrinted: false,
};
}
function compactActionableSummary<T>(
items: T[],
selectors: {
passed: (item: T) => boolean;
missing: (item: T) => boolean;
actionable: (item: T) => boolean;
project: (item: T) => unknown;
},
): Record<string, unknown> {
const actionableItems = items.filter(selectors.actionable);
const visibleItems = actionableItems.slice(0, compactActionablePreviewLimit);
return {
total: items.length,
passed: items.filter(selectors.passed).length,
missing: items.filter(selectors.missing).length,
actionable: actionableItems.length,
listed: visibleItems.length,
omitted: items.length - visibleItems.length,
actionableOmitted: actionableItems.length - visibleItems.length,
items: visibleItems.map(selectors.project),
};
}
function compactRemoteFacts(facts: RemoteFacts | null): Record<string, unknown> | null {
if (facts === null) return null;
return {
ok: facts.ok,
observedAt: facts.observedAt,
host: {
hostname: facts.host.hostname,
cpuCount: facts.host.cpuCount,
memoryTotalMiB: facts.host.memoryTotalMiB,
swapTotalMiB: facts.host.swapTotalMiB,
rootAvailGiB: facts.host.rootAvailGiB,
targetDataAvailGiB: facts.host.targetDataAvailGiB,
},
network: {
port5432Listening: facts.network.port5432Listening,
dns: facts.network.dns,
},
repo: {
reachable: facts.repo.reachable,
releaseUrl: facts.repo.releaseUrl,
},
postgres: {
packageInstalled: facts.postgres.packageInstalled,
serviceActive: facts.postgres.serviceActive,
serviceEnabled: facts.postgres.serviceEnabled,
roleCount: facts.postgres.roles.length,
databaseCount: facts.postgres.databases.length,
serverVersion: facts.postgres.serverVersion,
sslOn: facts.postgres.sslOn,
},
};
}
function compactPlanCapture(result: SshCaptureResult): Record<string, unknown> {
return {
exitCode: result.exitCode,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
stdoutTail: result.exitCode === 0 ? "" : result.stdout.slice(-1200),
stderrTail: result.exitCode === 0 ? "" : result.stderr.slice(-1200),
};
}
function requireMonitorResetConfig(pg: PostgresHostConfig): MonitorResetConfig {
const target = pg.managedOperations?.monitorReset;
if (target === undefined) throw new Error(`${pg.configPath}.managedOperations.monitorReset is required`);
@@ -1252,8 +1619,12 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI
const rendered = renderConnectionString(item, source.values);
const root = secretRoot(pg);
const targetPath = join(root, item.writeToSecretSource.sourceRef);
const existing = existsSync(targetPath) ? parseEnvFile(readFileSync(targetPath, "utf8")) : {};
const exists = existsSync(targetPath);
const existing = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {};
const before = existing[item.writeToSecretSource.key];
const beforePresent = before !== undefined && before.length > 0;
const beforeFingerprint = beforePresent ? fingerprintValues({ [item.writeToSecretSource.key]: before }, [item.writeToSecretSource.key]) : null;
const desiredFingerprint = fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]);
const next = { ...existing, [item.writeToSecretSource.key]: rendered };
if (materialize) writeEnvFile(targetPath, next);
return {
@@ -1261,13 +1632,45 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI
sourceRef: item.sourceSecretRef,
targetRef: item.writeToSecretSource.sourceRef,
key: item.writeToSecretSource.key,
before: {
exists,
present: beforePresent,
fingerprint: beforeFingerprint,
},
after: {
exists: materialize ? true : exists,
present: materialize ? true : beforePresent,
fingerprint: materialize ? desiredFingerprint : beforeFingerprint,
},
action: before === rendered ? "none" : before === undefined ? "create" : "update",
fingerprint: fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]),
desiredFingerprint,
consumers: item.consumers,
valuesPrinted: false,
};
}
function inspectExportTargets(pg: PostgresHostConfig): Array<Record<string, unknown>> {
const root = secretRoot(pg);
return pg.exports.connectionStrings.map((item) => {
const targetPath = join(root, item.writeToSecretSource.sourceRef);
const exists = existsSync(targetPath);
const values = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {};
const value = values[item.writeToSecretSource.key];
const present = value !== undefined && value.length > 0;
return {
name: item.name,
sourceRef: item.sourceSecretRef,
targetRef: item.writeToSecretSource.sourceRef,
key: item.writeToSecretSource.key,
exists,
present,
fingerprint: present ? fingerprintValues({ [item.writeToSecretSource.key]: value }, [item.writeToSecretSource.key]) : null,
consumerScopes: item.consumers.map((consumer) => consumer.scope),
valuesPrinted: false,
};
});
}
function renderConnectionString(item: ConnectionStringExportConfig, values: Record<string, string>): string {
let output = item.render.format;
const merged = { ...values, ...(item.render.variables ?? {}) };
@@ -4,7 +4,7 @@ import {
readGiteaConfig,
resolveTarget,
} from "./platform-infra-gitea-config";
import { buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea";
import { buildGiteaMirrorBootstrapCredentialPreflight, buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea";
import { renderMirrorWebhookCredentialBlocked } from "./platform-infra-gitea-render";
function syntheticMissingSelfmediaCredential(): Record<string, unknown> {
@@ -65,6 +65,28 @@ describe("platform-infra Gitea repository GitHub credentials", () => {
expect(nc01Overrides.map((credential) => credential.sourceRef)).toContain("pikainc-selfmedia-gh-token.txt");
});
test("bootstrap preflight only requires the selected repository effective credential", () => {
const config = readGiteaConfig();
const repository = config.sourceAuthority.repositories.find((repo) => repo.key === "selfmedia-nc01");
expect(repository).toBeDefined();
const result = buildGiteaMirrorBootstrapCredentialPreflight(config, [repository!], (credential) => (
credential.sourceRef === "pikainc-selfmedia-gh-token.txt"
? { [credential.sourceKey]: "fixture-token" }
: null
));
expect(result.blockers).toEqual([]);
expect(result.credentials).toHaveLength(1);
expect(result.credentials[0]).toMatchObject({
id: "github-upstream:selfmedia-nc01",
sourceRef: "pikainc-selfmedia-gh-token.txt",
present: true,
requiredKeysPresent: true,
});
expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.admin.sourceRef);
expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.github.sourceRef);
});
test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => {
const config = readGiteaConfig();
const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia");
@@ -221,11 +221,45 @@ payload = {
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
}
run_mirror_preflight() {
python3 - "$tmp/repos.json" <<'PY'
import base64, json, os, subprocess, sys
repos = json.load(open(sys.argv[1], encoding="utf-8"))
rows = []
timeout = int(os.environ.get("UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS", "20"))
for repo in repos:
credential = repo.get("githubCredential") or {}
token_env = credential.get("tokenEnv") or os.environ.get("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV", "")
token = os.environ.get(token_env, "")
clone_url = repo["upstream"]["cloneUrl"]
branch = repo["upstream"]["branch"]
if not token:
rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": False, "code": "github-credential-unavailable", "valuesPrinted": False})
continue
basic = base64.b64encode(("x-access-token:" + token).encode()).decode()
try:
completed = subprocess.run(
["git", "-c", "http.extraHeader=Authorization: Basic " + basic, "ls-remote", "--exit-code", clone_url, "refs/heads/" + branch],
capture_output=True,
text=True,
timeout=timeout,
)
error = " ".join(completed.stderr.split())[-800:]
rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": completed.returncode == 0, "code": "github-upstream-available" if completed.returncode == 0 else "github-repository-not-found-or-no-access", "exitCode": completed.returncode, "errorTail": error, "valuesPrinted": False})
except subprocess.TimeoutExpired:
rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": False, "code": "github-upstream-timeout", "valuesPrinted": False})
payload = {"ok": bool(rows) and all(row["ok"] for row in rows), "mutation": False, "repositories": rows, "valuesPrinted": False}
print(json.dumps(payload, ensure_ascii=False))
sys.exit(0 if payload["ok"] else 1)
PY
}
run_status() {
selector="app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea"
capture_json namespace kubectl get namespace "$UNIDESK_GITEA_NAMESPACE"
@@ -1125,6 +1159,7 @@ case "$UNIDESK_GITEA_ACTION" in
apply) run_apply ;;
status) run_status ;;
validate) run_validate ;;
mirror-preflight) run_mirror_preflight ;;
mirror-bootstrap) run_mirror_bootstrap ;;
mirror-sync) run_mirror_sync ;;
mirror-status) run_mirror_status ;;
+64 -1
View File
@@ -123,6 +123,69 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args:
};
}
export async function runGiteaMirrorBootstrapPreflight(
config: UniDeskConfig,
targetId: string,
repoKey: string,
): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveCommandTarget(gitea, targetId);
const repos = selectedRepositories(gitea, target, repoKey);
const { credentials, blockers } = buildGiteaMirrorBootstrapCredentialPreflight(gitea, repos);
if (blockers.length > 0) {
return { ok: false, mutation: false, code: "github-credential-unavailable", blockers, repositories: repos.map((repo) => repositorySummary(gitea, repo)), valuesPrinted: false };
}
const secrets = mirrorPreflightSecrets(gitea, repos);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-preflight", gitea, target, "", { targetId, repoKey, confirm: false, dryRun: true, wait: false, full: false, raw: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return parsed ?? { ok: false, mutation: false, code: "github-upstream-preflight-failed", remote: compactCapture(result, { full: true }), valuesPrinted: false };
}
export function buildGiteaMirrorBootstrapCredentialPreflight(
gitea: GiteaConfig,
repositories: GiteaMirrorRepository[],
readCredential: (credential: GiteaGithubCredential) => Record<string, string> | null = (credential) => readGithubCredential(gitea, credential),
): { credentials: Array<Record<string, unknown>>; blockers: string[] } {
const credentials = repositories.map((repo) => {
const github = githubCredentialForRepo(gitea, repo);
const values = readCredential(github);
const present = values !== null;
const requiredKeysPresent = Boolean(values?.[github.sourceKey]);
return {
id: `github-upstream:${repo.key}`,
repository: repo.upstream.repository,
sourceRef: github.sourceRef,
present,
requiredKeysPresent,
fingerprint: present ? fingerprintKeys(values, [github.sourceKey]) : null,
permissionResult: present && requiredKeysPresent
? { status: "not-probed", permissions: github.permissions, error: null }
: { status: "blocked-missing-credential", permissions: github.permissions, error: "credential material is absent" },
valuesPrinted: false,
};
});
return { credentials, blockers: credentialBlockers(credentials) };
}
function readGithubCredential(gitea: GiteaConfig, github: GiteaGithubCredential): Record<string, string> | null {
const sourcePath = credentialPath(gitea, github.sourceRef);
return existsSync(sourcePath) ? parseGithubCredentialFile(sourcePath, github) : null;
}
function mirrorPreflightSecrets(gitea: GiteaConfig, repositories: GiteaMirrorRepository[]): MirrorSecrets {
const githubTokens: Record<string, string> = {};
for (const repo of repositories) {
const github = githubCredentialForRepo(gitea, repo);
const githubPath = credentialPath(gitea, github.sourceRef);
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot probe GitHub upstream`);
const githubEnv = parseGithubCredentialFile(githubPath, github);
const githubToken = githubEnv[github.sourceKey];
if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`);
githubTokens[github.gitFetchCredential.secretRef.key] = githubToken;
}
return { adminUsername: "", adminPassword: "", githubTokens, webhookSecret: "" };
}
export function giteaHelp(scope?: string): Record<string, unknown> {
if (scope === "platform-bootstrap") {
return {
@@ -1116,7 +1179,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
value: ${yamlQuote(value)}`).join("\n");
}
function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record<string, unknown> } {
function remoteScript(action: "apply" | "status" | "validate" | "mirror-preflight" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record<string, unknown> } {
const frpcExposure = targetFrpcExposure(gitea, target);
const sync = targetWebhookSync(gitea, target);
const env: Record<string, string> = {
@@ -3,7 +3,12 @@ import { spawnSync } from "node:child_process";
import { rootPath } from "./config";
import { isRenderedCliResult } from "./output";
import { runPlatformObservabilityCommand } from "./platform-infra-observability";
import { resolveTarget } from "./platform-infra-observability/actions";
import { isKubernetesLabelValue, isKubernetesQualifiedName, readObservabilityConfig } from "./platform-infra-observability/config";
import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render";
import { prometheusMetaLabel, re2Literal } from "./platform-infra-observability/prometheus";
import { statusScript } from "./platform-infra-observability/search-script";
import { renderManifest } from "./platform-infra-observability/trace-script";
describe("platform-infra observability progressive disclosure", () => {
test("default plan is a bounded table while full and raw preserve the complete plan", async () => {
@@ -13,6 +18,8 @@ describe("platform-infra observability progressive disclosure", () => {
expect(Buffer.byteLength(compact.renderedText, "utf8")).toBeLessThan(10_240);
expect(compact.renderedText).toContain("serviceConnections=8");
expect(compact.renderedText).toContain("configRefs=6/6 missing=0");
expect(compact.renderedText).toContain("prometheus=enabled service=prometheus targets=NC01");
expect(compact.renderedText).toContain("scrapeSelector=unidesk.ai/metrics=true");
expect(compact.renderedText).toContain("--full");
expect(compact.renderedText).toContain("--raw");
@@ -25,6 +32,7 @@ describe("platform-infra observability progressive disclosure", () => {
renderPlan: { target: { id: "NC01", route: "NC01:k3s", namespace: "platform-infra" } },
});
expect((expanded as Record<string, any>).renderPlan.instrumentation).toHaveLength(8);
expect((expanded as Record<string, any>).config.metricsBackend).toMatchObject({ type: "prometheus", enabled: true });
}
});
@@ -38,8 +46,66 @@ describe("platform-infra observability progressive disclosure", () => {
expect(result.stdout).not.toContain("/tmp/unidesk-cli-output");
});
test("search, trace and diagnose-code-agent expose only scoped help", () => {
for (const operation of ["search", "trace", "diagnose-code-agent"]) {
test("Prometheus manifests and runtime access only apply to YAML-selected targets", async () => {
const observability = readObservabilityConfig();
const nc01 = resolveTarget(observability, "NC01");
const d518 = resolveTarget(observability, "D518");
const jd01 = resolveTarget(observability, "JD01");
const nc01Manifest = renderManifest(observability, nc01);
for (const target of [d518, jd01]) {
const manifest = renderManifest(observability, target);
expect(manifest).not.toContain("kind: ClusterRole\nmetadata:\n name: platform-infra-prometheus");
expect(manifest).not.toContain("kind: PersistentVolumeClaim\nmetadata:\n name: prometheus-data");
expect(manifest).not.toContain("app.kubernetes.io/component: metrics-backend");
const plan = await runPlatformObservabilityCommand({} as never, ["plan", "--target", target.id]);
expect(isRenderedCliResult(plan)).toBe(true);
if (!isRenderedCliResult(plan)) throw new Error("expected rendered plan");
expect(plan.renderedText).toContain("prometheus=disabled-for-target");
expect(plan.renderedText).toContain("objects=8");
const script = statusScript(observability, target, false);
expect(script).not.toContain("capture_json metrics_deployments");
expect(script).not.toContain("prometheus-ready");
const query = await runPlatformObservabilityCommand({} as never, ["metrics-query", "--target", target.id, "--query", "up", "--full"]);
expect(query).toMatchObject({ metrics: { enabled: false, disposition: "disabled-for-target", targetIds: ["NC01"] } });
}
expect(nc01Manifest).toContain("kind: ClusterRole\nmetadata:\n name: platform-infra-prometheus");
expect(nc01Manifest).toContain("kind: PersistentVolumeClaim\nmetadata:\n name: prometheus-data");
});
test("Prometheus relabel rules prefer an explicit path and only default when absent", () => {
const observability = readObservabilityConfig();
const manifest = renderManifest(observability, resolveTarget(observability, "NC01"));
const explicitPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)";
const defaultPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: /metrics";
expect(manifest).toContain("source_labels: [__meta_kubernetes_pod_label_unidesk_ai_metrics]");
expect(manifest).toContain("regex: '^true$'");
expect(manifest).toContain(explicitPath);
expect(manifest).toContain(defaultPath);
expect(manifest.indexOf(explicitPath)).toBeLessThan(manifest.indexOf(defaultPath));
});
test("Prometheus selector rendering uses qualified names, stable meta labels and literal RE2", () => {
const observability = readObservabilityConfig();
observability.metricsBackend.discovery.labelSelector = {
key: "metrics.example.com/release-track",
value: "v1.2-beta",
};
observability.metricsBackend.discovery.annotationKeys.scrape = "prometheus.io/scrape-enabled";
const manifest = renderManifest(observability, resolveTarget(observability, "NC01"));
expect(manifest).toContain("__meta_kubernetes_pod_label_metrics_example_com_release_track");
expect(manifest).toContain("__meta_kubernetes_pod_annotation_prometheus_io_scrape_enabled");
expect(manifest).toContain("regex: '^v1\\.2-beta$'");
expect(prometheusMetaLabel("a.b/c-d_e")).toBe("a_b_c_d_e");
expect(re2Literal("v1.2-beta")).toBe("^v1\\.2-beta$");
expect(isKubernetesQualifiedName("metrics.example.com/release-track")).toBe(true);
expect(isKubernetesQualifiedName("metrics.example.com/release/track")).toBe(false);
expect(isKubernetesQualifiedName("Metrics.example.com/release-track")).toBe(false);
expect(isKubernetesQualifiedName(`${"a".repeat(64)}.example/release-track`)).toBe(false);
expect(isKubernetesLabelValue("v1.2-beta")).toBe(true);
});
test("query commands expose only scoped help", () => {
for (const operation of ["search", "trace", "diagnose-code-agent", "metrics-query"]) {
const result = cli(["platform-infra", "observability", operation, "--help"]);
expect(result.status).toBe(0);
const payload = JSON.parse(result.stdout) as Record<string, any>;
@@ -57,7 +123,7 @@ describe("platform-infra observability progressive disclosure", () => {
expect(result.status).toBe(0);
const payload = JSON.parse(result.stdout) as Record<string, any>;
expect(payload.data.operations.map((item: Record<string, unknown>) => item.name)).toEqual([
"plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent",
"plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query",
]);
expect(payload.data.scopedHelp).toContain("<operation> --help");
expect(payload.data.usage).toBeUndefined();
@@ -27,6 +27,7 @@ import { compactStatus, configSummary, manifestObjectSummary, policyChecks, stat
import { renderManifest } from "./trace-script";
import { formatTable, shortenEnd, textValue } from "./manifest";
import { apiPathField, configLabel, kubernetesNameField, stringField } from "./types";
import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target";
export function parseStatusEndpoint(record: Record<string, unknown>, index: number): StatusEndpoint {
const path = `probes.statusEndpoints[${index}]`;
@@ -57,6 +58,7 @@ export function plan(options: CommonOptions): Record<string, unknown> {
const target = resolveTarget(observability, options.targetId);
const yaml = renderManifest(observability, target);
const policy = policyChecks(yaml, target);
const metricsDisposition = metricsTargetDisposition(observability, target);
return {
ok: policy.every((check) => check.ok),
action: "platform-infra-observability-plan",
@@ -65,10 +67,16 @@ export function plan(options: CommonOptions): Record<string, unknown> {
renderPlan: {
target: targetSummary(target),
objects: manifestObjectSummary(yaml),
metrics: {
disposition: metricsDisposition,
targetIds: observability.metricsBackend.targetIds,
},
otlp: {
collectorGrpcEndpoint: `${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.grpcPort}`,
collectorHttpEndpoint: `http://${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.httpPort}`,
backendGrpcEndpoint: `${observability.traceBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.traceBackend.otlp.grpcPort}`,
prometheusHttpEndpoint: metricsEnabledForTarget(observability, target) ? `http://${observability.metricsBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.metricsBackend.httpPort}` : null,
scrapeDiscovery: observability.metricsBackend.discovery,
},
instrumentation: observability.instrumentation.serviceConnections,
resourceAttributes: observability.resourceAttributes,
@@ -80,6 +88,7 @@ export function plan(options: CommonOptions): Record<string, unknown> {
status: `bun scripts/cli.ts platform-infra observability status --target ${target.id}`,
validate: `bun scripts/cli.ts platform-infra observability validate --target ${target.id}`,
trace: `bun scripts/cli.ts platform-infra observability trace --target ${target.id} --trace-id <traceId>`,
metricsQuery: `bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query <promql>`,
},
};
}
@@ -39,6 +39,12 @@ export function readObservabilityConfig(): ObservabilityConfig {
const traceBackend = objectField(root, "traceBackend", "");
const traceBackendOtlp = objectField(traceBackend, "otlp", "traceBackend");
const traceBackendStorage = objectField(traceBackend, "storage", "traceBackend");
const metricsBackend = objectField(root, "metricsBackend", "");
const metricsStorage = objectField(metricsBackend, "storage", "metricsBackend");
const metricsDiscovery = objectField(metricsBackend, "discovery", "metricsBackend");
const metricsLabelSelector = objectField(metricsDiscovery, "labelSelector", "metricsBackend.discovery");
const metricsAnnotations = objectField(metricsDiscovery, "annotationKeys", "metricsBackend.discovery");
const metricsQuery = objectField(metricsBackend, "query", "metricsBackend");
const sampling = objectField(root, "sampling", "");
const instrumentation = objectField(root, "instrumentation", "");
const resourceAttributes = objectField(root, "resourceAttributes", "");
@@ -56,6 +62,7 @@ export function readObservabilityConfig(): ObservabilityConfig {
images: {
collector: imageSpec(objectField(images, "collector", "images"), "images.collector"),
tempo: imageSpec(objectField(images, "tempo", "images"), "images.tempo"),
prometheus: imageSpec(objectField(images, "prometheus", "images"), "images.prometheus"),
},
targets: arrayOfRecords(root.targets, "targets").map(parseTarget),
collector: {
@@ -79,6 +86,46 @@ export function readObservabilityConfig(): ObservabilityConfig {
retention: stringField(traceBackendStorage, "retention", "traceBackend.storage"),
},
},
metricsBackend: {
type: enumField(metricsBackend, "type", "metricsBackend", ["prometheus"] as const),
enabled: booleanField(metricsBackend, "enabled", "metricsBackend"),
targetIds: stringArrayField(metricsBackend, "targetIds", "metricsBackend"),
deploymentName: kubernetesNameField(metricsBackend, "deploymentName", "metricsBackend"),
serviceName: kubernetesNameField(metricsBackend, "serviceName", "metricsBackend"),
configMapName: kubernetesNameField(metricsBackend, "configMapName", "metricsBackend"),
serviceAccountName: kubernetesNameField(metricsBackend, "serviceAccountName", "metricsBackend"),
clusterRoleName: kubernetesNameField(metricsBackend, "clusterRoleName", "metricsBackend"),
clusterRoleBindingName: kubernetesNameField(metricsBackend, "clusterRoleBindingName", "metricsBackend"),
persistentVolumeClaimName: kubernetesNameField(metricsBackend, "persistentVolumeClaimName", "metricsBackend"),
replicas: integerField(metricsBackend, "replicas", "metricsBackend"),
httpPort: portField(metricsBackend, "httpPort", "metricsBackend"),
storage: {
mode: enumField(metricsStorage, "mode", "metricsBackend.storage", ["persistentVolumeClaim"] as const),
size: stringField(metricsStorage, "size", "metricsBackend.storage"),
retention: stringField(metricsStorage, "retention", "metricsBackend.storage"),
},
discovery: {
namespaces: stringArrayField(metricsDiscovery, "namespaces", "metricsBackend.discovery"),
labelSelector: {
key: stringField(metricsLabelSelector, "key", "metricsBackend.discovery.labelSelector"),
value: stringField(metricsLabelSelector, "value", "metricsBackend.discovery.labelSelector"),
},
annotationKeys: {
scrape: stringField(metricsAnnotations, "scrape", "metricsBackend.discovery.annotationKeys"),
path: stringField(metricsAnnotations, "path", "metricsBackend.discovery.annotationKeys"),
port: stringField(metricsAnnotations, "port", "metricsBackend.discovery.annotationKeys"),
},
defaultPath: apiPathField(metricsDiscovery, "defaultPath", "metricsBackend.discovery"),
scrapeInterval: stringField(metricsDiscovery, "scrapeInterval", "metricsBackend.discovery"),
scrapeTimeout: stringField(metricsDiscovery, "scrapeTimeout", "metricsBackend.discovery"),
},
query: {
path: apiPathField(metricsQuery, "path", "metricsBackend.query"),
timeoutSeconds: integerField(metricsQuery, "timeoutSeconds", "metricsBackend.query"),
maxSeries: integerField(metricsQuery, "maxSeries", "metricsBackend.query"),
maxResponseBytes: integerField(metricsQuery, "maxResponseBytes", "metricsBackend.query"),
},
},
sampling: {
mode: enumField(sampling, "mode", "sampling", ["parentbased_traceidratio"] as const),
ratio: numberField(sampling, "ratio", "sampling"),
@@ -99,12 +146,37 @@ export function readObservabilityConfig(): ObservabilityConfig {
};
if (config.targets.length === 0) throw new Error(`${configLabel}.targets must not be empty`);
assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId");
if (config.collector.replicas < 0 || config.traceBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`);
for (const targetId of config.metricsBackend.targetIds) assertKnownEnabledTarget(config.targets, targetId, "metricsBackend.targetIds");
if (config.metricsBackend.enabled && config.metricsBackend.targetIds.length === 0) throw new Error(`${configLabel}.metricsBackend.targetIds must not be empty when metricsBackend.enabled is true`);
if (new Set(config.metricsBackend.targetIds.map((targetId) => targetId.toLowerCase())).size !== config.metricsBackend.targetIds.length) throw new Error(`${configLabel}.metricsBackend.targetIds must not contain duplicates`);
if (!isKubernetesQualifiedName(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes qualified name`);
if (!isKubernetesLabelValue(config.metricsBackend.discovery.labelSelector.value)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`);
if (config.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`);
if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`);
if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`);
if (!config.probes.traceQueryPathTemplate.includes("{{traceId}}")) throw new Error(`${configLabel}.probes.traceQueryPathTemplate must include {{traceId}}`);
return config;
}
export function isKubernetesQualifiedName(value: string): boolean {
const slash = value.indexOf("/");
if (slash !== value.lastIndexOf("/")) return false;
const prefix = slash < 0 ? null : value.slice(0, slash);
const name = slash < 0 ? value : value.slice(slash + 1);
if (!isKubernetesNamePart(name)) return false;
if (prefix === null) return true;
if (prefix.length === 0 || prefix.length > 253) return false;
return prefix.split(".").every((segment) => segment.length <= 63 && /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(segment));
}
export function isKubernetesLabelValue(value: string): boolean {
return value.length <= 63 && (value === "" || isKubernetesNamePart(value));
}
function isKubernetesNamePart(value: string): boolean {
return value.length > 0 && value.length <= 63 && /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u.test(value);
}
export function imageSpec(record: Record<string, unknown>, path: string): ImageSpec {
const image = {
repository: stringField(record, "repository", path),
@@ -0,0 +1,12 @@
import type { ObservabilityConfig, ObservabilityTarget } from "./types";
export type MetricsTargetDisposition = "enabled" | "disabled" | "disabled-for-target";
export function metricsTargetDisposition(observability: ObservabilityConfig, target: ObservabilityTarget): MetricsTargetDisposition {
if (!observability.metricsBackend.enabled) return "disabled";
return observability.metricsBackend.targetIds.some((targetId) => targetId.toLowerCase() === target.id.toLowerCase()) ? "enabled" : "disabled-for-target";
}
export function metricsEnabledForTarget(observability: ObservabilityConfig, target: ObservabilityTarget): boolean {
return metricsTargetDisposition(observability, target) === "enabled";
}
@@ -19,18 +19,19 @@ import {
capture,
} from "../platform-infra-ops-library";
import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, SearchOptions, TraceOptions } from "./types";
import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, MetricsQueryOptions, SearchOptions, TraceOptions } from "./types";
import { apply, plan, status, validate } from "./actions";
import { diagnoseCodeAgent, search, trace } from "./render";
import { renderObservabilityPlan, renderObservabilityPlanFailure } from "./plan-render";
import { metricsQuery } from "./prometheus";
const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent"] as const;
const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query"] as const;
export function observabilityHelp(scope: string | null = null): Record<string, unknown> {
const scoped = scope === null ? null : observabilityOperationHelp(scope);
if (scoped !== null) return scoped;
return {
command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent",
command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent|metrics-query",
output: "默认输出为有界摘要;--full/--raw 显式披露完整结构",
configTruth: "config/platform-infra/observability.yaml",
spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0",
@@ -39,7 +40,7 @@ export function observabilityHelp(scope: string | null = null): Record<string, u
help: `bun scripts/cli.ts platform-infra observability ${name} --help`,
})),
scopedHelp: "bun scripts/cli.ts platform-infra observability <operation> --help",
boundary: "Prometheus 仍是指标来源;本命令只负责 platform-infra OTel CollectorTempo readiness 与 trace 查询。",
boundary: "Collector + Tempo 是 trace authorityPrometheus 仅承担 metrics,故障和漂移保持非阻塞 warning。",
};
}
@@ -62,6 +63,7 @@ export async function runPlatformObservabilityCommand(config: UniDeskConfig, arg
if (action === "trace") return await trace(config, parseTraceOptions(args.slice(1)));
if (action === "search") return await search(config, parseSearchOptions(args.slice(1)));
if (action === "diagnose-code-agent") return await diagnoseCodeAgent(config, parseDiagnoseCodeAgentOptions(args.slice(1)));
if (action === "metrics-query") return await metricsQuery(config, parseMetricsQueryOptions(args.slice(1)));
return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() };
}
@@ -105,6 +107,12 @@ function observabilityOperationHelp(scope: string): Record<string, unknown> | nu
],
options: ["--trace-id <32-hex>", "--target <NODE>", "--grep <text>", "--limit <1..500>", "--full", "--raw"],
};
if (scope === "metrics-query") return {
...common,
command: "platform-infra observability metrics-query",
usage: ["bun scripts/cli.ts platform-infra observability metrics-query --target <NODE> --query <promql> [--full|--raw]"],
options: ["--target <NODE>", "--query <promql>", "--full", "--raw"],
};
if (scope === "search") return {
...common,
command: "platform-infra observability search",
@@ -126,6 +134,27 @@ function observabilityOperationHelp(scope: string): Record<string, unknown> | nu
return null;
}
export function parseMetricsQueryOptions(args: string[]): MetricsQueryOptions {
const commonArgs: string[] = [];
let query: string | null = null;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--query") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error("--query requires a value");
query = value;
index += 1;
} else {
commonArgs.push(arg);
if (arg === "--target") {
commonArgs.push(args[index + 1] ?? "");
index += 1;
}
}
}
return { ...parseCommonOptions(commonArgs), query };
}
export function parseCommonOptions(args: string[]): CommonOptions {
let targetId: string | null = null;
let full = false;
@@ -8,7 +8,9 @@ export function renderObservabilityPlan(result: Record<string, unknown>): Render
const images = record(config.images);
const collector = record(config.collector);
const traceBackend = record(config.traceBackend);
const metricsBackend = record(config.metricsBackend);
const renderPlan = record(result.renderPlan);
const metricsPlan = record(renderPlan.metrics);
const otlp = record(renderPlan.otlp);
const connections = records(config.serviceConnections);
const objects = records(renderPlan.objects);
@@ -39,6 +41,8 @@ export function renderObservabilityPlan(result: Record<string, unknown>): Render
`target=${textValue(target.id)} route=${textValue(target.route)} namespace=${textValue(target.namespace)} role=${textValue(target.role)}`,
`collector=${textValue(collector.serviceName)} replicas=${textValue(collector.replicas)} image=${textValue(images.collector)}`,
`tempo=${textValue(traceBackend.serviceName)} replicas=${textValue(traceBackend.replicas)} retention=${textValue(record(traceBackend.storage).retention)} image=${textValue(images.traceBackend)}`,
`prometheus=${textValue(metricsPlan.disposition)} service=${textValue(metricsBackend.serviceName)} targets=${values(metricsPlan.targetIds).join(",")} replicas=${textValue(metricsBackend.replicas)} retention=${textValue(record(metricsBackend.storage).retention)} image=${textValue(images.metricsBackend)}`,
`scrapeSelector=${textValue(record(record(metricsBackend.discovery).labelSelector).key)}=${textValue(record(record(metricsBackend.discovery).labelSelector).value)} namespaces=${values(record(metricsBackend.discovery).namespaces).length === 0 ? "all" : values(record(metricsBackend.discovery).namespaces).join(",")} queryBudget=${textValue(record(metricsBackend.query).timeoutSeconds)}s/${textValue(record(metricsBackend.query).maxSeries)}series/${textValue(record(metricsBackend.query).maxResponseBytes)}bytes`,
`serviceConnections=${connections.length} services=${serviceNames.size} nodes=${nodes.size} configRefs=${presentConfigRefs.length}/${configRefs.length} missing=${missingConfigRefs.length} objects=${objects.length}`,
"",
"Service connections:",
@@ -77,6 +81,7 @@ export function renderObservabilityPlan(result: Record<string, unknown>): Render
` collector-grpc: ${textValue(otlp.collectorGrpcEndpoint)}`,
` collector-http: ${textValue(otlp.collectorHttpEndpoint)}`,
` tempo-grpc: ${textValue(otlp.backendGrpcEndpoint)}`,
` prometheus-http: ${textValue(otlp.prometheusHttpEndpoint)}`,
"",
"Next:",
` bun scripts/cli.ts platform-infra observability plan --target ${textValue(target.id)} --full`,
@@ -0,0 +1,53 @@
import type { UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { capture, compactCapture, parseJsonOutput, redactSensitiveUnknown, shQuote } from "../platform-infra-ops-library";
import { resolveTarget } from "./actions";
import { readObservabilityConfig } from "./config";
import { formatTable, shortenEnd, textValue } from "./manifest";
import { imageReference, indent, targetSummary } from "./summary";
import type { MetricsQueryOptions, ObservabilityConfig, ObservabilityTarget } from "./types";
import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target";
export function prometheusManifests(observability: ObservabilityConfig, target: ObservabilityTarget): string[] {
const prometheus = observability.metricsBackend;
if (!metricsEnabledForTarget(observability, target)) return [];
const namespaceNames = prometheus.discovery.namespaces.length > 0
? `\n namespaces:\n names:\n${prometheus.discovery.namespaces.map((name) => ` - ${name}`).join("\n")}`
: "";
const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${prometheusMetaLabel(prometheus.discovery.labelSelector.key)}]\n action: keep\n regex: '${re2Literal(prometheus.discovery.labelSelector.value)}'\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: ${prometheus.discovery.defaultPath}\n`;
const labels = ` app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n app.kubernetes.io/managed-by: unidesk`;
return [
`apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n labels:\n${labels}\n`,
`apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: ${prometheus.clusterRoleName}\nrules:\n - apiGroups: [\"\"]\n resources: [\"nodes\", \"nodes/proxy\", \"services\", \"endpoints\", \"pods\"]\n verbs: [\"get\", \"list\", \"watch\"]\n`,
`apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n name: ${prometheus.clusterRoleBindingName}\nroleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: ClusterRole\n name: ${prometheus.clusterRoleName}\nsubjects:\n - kind: ServiceAccount\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n`,
`apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ${prometheus.configMapName}\n namespace: ${target.namespace}\n labels:\n${labels}\ndata:\n prometheus.yml: |\n${indent(config, 4)}\n`,
`apiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: ${prometheus.persistentVolumeClaimName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n accessModes: [ReadWriteOnce]\n resources:\n requests:\n storage: ${prometheus.storage.size}\n`,
`apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: ${prometheus.deploymentName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n replicas: ${prometheus.replicas}\n selector:\n matchLabels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n template:\n metadata:\n labels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n spec:\n serviceAccountName: ${prometheus.serviceAccountName}\n containers:\n - name: prometheus\n image: ${imageReference(observability.images.prometheus)}\n imagePullPolicy: ${observability.images.prometheus.pullPolicy}\n args:\n - --config.file=/etc/prometheus/prometheus.yml\n - --storage.tsdb.path=/prometheus\n - --storage.tsdb.retention.time=${prometheus.storage.retention}\n ports:\n - name: http\n containerPort: ${prometheus.httpPort}\n readinessProbe:\n httpGet:\n path: /-/ready\n port: http\n volumeMounts:\n - name: config\n mountPath: /etc/prometheus/prometheus.yml\n subPath: prometheus.yml\n readOnly: true\n - name: data\n mountPath: /prometheus\n volumes:\n - name: config\n configMap:\n name: ${prometheus.configMapName}\n - name: data\n persistentVolumeClaim:\n claimName: ${prometheus.persistentVolumeClaimName}\n`,
`apiVersion: v1\nkind: Service\nmetadata:\n name: ${prometheus.serviceName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n type: ClusterIP\n selector:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n ports:\n - name: http\n port: ${prometheus.httpPort}\n targetPort: http\n`,
];
}
export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryOptions): Promise<Record<string, unknown> | RenderedCliResult> {
if (options.query === null || options.query.trim() === "") throw new Error("observability metrics-query requires --query <promql>");
const observability = readObservabilityConfig();
const target = resolveTarget(observability, options.targetId);
const prometheus = observability.metricsBackend;
const disposition = metricsTargetDisposition(observability, target);
if (disposition !== "enabled") return { ok: false, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), metrics: { enabled: false, disposition, targetIds: prometheus.targetIds }, warnings: [`Prometheus is ${disposition} in config/platform-infra/observability.yaml`] };
const path = `${prometheus.query.path}?query=${encodeURIComponent(options.query)}`;
const script = `set -u\npython3 - <<'PY'\nimport json, subprocess\npath = ${JSON.stringify(`/api/v1/namespaces/${target.namespace}/services/http:${prometheus.serviceName}:http/proxy${path}`)}\nproc = subprocess.run([\"kubectl\", \"get\", \"--raw\", path], text=True, capture_output=True, timeout=${prometheus.query.timeoutSeconds})\nbody = proc.stdout[:${prometheus.query.maxResponseBytes}]\ntry:\n parsed = json.loads(body) if body else None\nexcept Exception:\n parsed = None\nresult = parsed.get(\"data\", {}).get(\"result\", []) if isinstance(parsed, dict) else []\nprint(json.dumps({\"ok\": proc.returncode == 0 and isinstance(parsed, dict) and parsed.get(\"status\") == \"success\", \"exitCode\": proc.returncode, \"truncated\": len(proc.stdout) > ${prometheus.query.maxResponseBytes} or len(result) > ${prometheus.query.maxSeries}, \"resultType\": parsed.get(\"data\", {}).get(\"resultType\") if isinstance(parsed, dict) else None, \"result\": result[:${prometheus.query.maxSeries}], \"stderrTail\": proc.stderr[-2000:]}, ensure_ascii=False))\nPY`;
const result = await capture(config, target.route, ["sh"], script);
const parsed = parseJsonOutput(result.stdout);
const ok = result.exitCode === 0 && parsed?.ok === true;
if (options.full || options.raw) return { ok, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), query: options.query, budgets: prometheus.query, result: parsed === null ? compactCapture(result, { full: true }) : redactSensitiveUnknown(parsed) };
const rows = Array.isArray(parsed?.result) ? parsed.result.slice(0, 20).map((item: unknown) => [shortenEnd(JSON.stringify(item), 160)]) : [];
return { ok, command: "platform-infra observability metrics-query", contentType: "text/plain", renderedText: [`platform-infra observability metrics-query (${ok ? "ok" : "not-ok"})`, "", `target=${target.id} prometheus=${prometheus.serviceName}.${target.namespace}.svc.cluster.local:${prometheus.httpPort}`, `query=${options.query}`, `budget=timeout:${prometheus.query.timeoutSeconds}s series:${prometheus.query.maxSeries} bytes:${prometheus.query.maxResponseBytes} truncated=${textValue(parsed?.truncated)}`, "", formatTable(["RESULT"], rows.length > 0 ? rows : [["-"]]), "", `Next: bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query ${shQuote(options.query)} --full`].join("\n") };
}
export function prometheusMetaLabel(value: string): string {
return value.replace(/[^A-Za-z0-9_]/gu, "_");
}
export function re2Literal(value: string): string {
return `^${value.replace(/[\\.^$|?*+()[\]{}]/gu, "\\$&")}$`;
}
@@ -21,6 +21,7 @@ import {
import type { ObservabilityConfig, ObservabilityTarget } from "./types";
import { fieldManager } from "./types";
import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target";
export function tempoService(observability: ObservabilityConfig, target: ObservabilityTarget): string {
return `apiVersion: v1
@@ -102,7 +103,15 @@ PY
}
export function statusScript(observability: ObservabilityConfig, target: ObservabilityTarget, full: boolean, generateValidationTrace = false): string {
const endpointsJson = JSON.stringify(observability.probes.statusEndpoints);
const metricsEnabled = metricsEnabledForTarget(observability, target);
const metricsDisposition = metricsTargetDisposition(observability, target);
const enabledEndpoints = metricsEnabled
? observability.probes.statusEndpoints
: observability.probes.statusEndpoints.filter((endpoint) => endpoint.service !== observability.metricsBackend.serviceName);
const endpointsJson = JSON.stringify(enabledEndpoints);
const metricsCaptures = metricsEnabled
? `capture_json metrics_deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.metricsBackend.deploymentName)} -o json\ncapture_json metrics_services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.metricsBackend.serviceName)} -o json\ncapture_json metrics_pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name=${observability.metricsBackend.deploymentName}`)} -o json`
: "";
return `
set -u
tmp="$(mktemp -d)"
@@ -123,6 +132,7 @@ capture_json namespace kubectl get namespace ${shQuote(target.namespace)} -o jso
capture_json deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.collector.deploymentName)} ${shQuote(observability.traceBackend.deploymentName)} -o json
capture_json services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.collector.serviceName)} ${shQuote(observability.traceBackend.serviceName)} -o json
capture_json pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name in (${observability.collector.deploymentName},${observability.traceBackend.deploymentName})`)} -o json
${metricsCaptures}
capture_json events kubectl -n ${shQuote(target.namespace)} get events -o json
python3 - "$tmp" '${endpointsJson.replaceAll("'", "'\"'\"'")}' <<'PY'
import json, random, socket, subprocess, sys, time, urllib.error, urllib.request
@@ -304,7 +314,9 @@ def generate_test_trace():
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=3)
runtime_ready = rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in probe_results)
blocking_probes = [item for item in probe_results if item.get("service") != "${observability.metricsBackend.serviceName}"]
metrics_probes = [item for item in probe_results if item.get("service") == "${observability.metricsBackend.serviceName}"]
runtime_ready = rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in blocking_probes)
validation_trace = generate_test_trace() if (${generateValidationTrace ? "True" : "False"} and runtime_ready) else None
payload = {
"ok": runtime_ready and (validation_trace is None or validation_trace.get("ok") is True),
@@ -316,8 +328,12 @@ payload = {
"services": compact_section("services"),
"pods": compact_section("pods"),
"events": compact_section("events"),
"metricsDeployments": compact_section("metrics_deployments") if ${metricsEnabled ? "True" : "False"} else None,
"metricsServices": compact_section("metrics_services") if ${metricsEnabled ? "True" : "False"} else None,
"metricsPods": compact_section("metrics_pods") if ${metricsEnabled ? "True" : "False"} else None,
},
"probes": probe_results,
"warnings": ([{"code": "prometheus-not-ready", "detail": item} for item in metrics_probes if item.get("ok") is not True] if ${metricsEnabled ? "True" : "False"} else [{"code": "prometheus-${metricsDisposition}"}]),
"validationTrace": validation_trace,
}
print(json.dumps(payload, ensure_ascii=False, indent=2 if ${full ? "True" : "False"} else None))
@@ -32,9 +32,11 @@ export function configSummary(observability: ObservabilityConfig, target: Observ
images: {
collector: imageReference(observability.images.collector),
traceBackend: imageReference(observability.images.tempo),
metricsBackend: imageReference(observability.images.prometheus),
},
collector: observability.collector,
traceBackend: observability.traceBackend,
metricsBackend: observability.metricsBackend,
sampling: observability.sampling,
serviceConnections: observability.instrumentation.serviceConnections.map((item) => ({
serviceName: item.serviceName,
@@ -62,8 +64,8 @@ export function targetSummary(target: ObservabilityTarget): Record<string, unkno
export function policyChecks(yaml: string, target: ObservabilityTarget): Array<Record<string, unknown>> {
return [
{ name: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention and sampling values are read from config/platform-infra/observability.yaml." },
{ name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector and trace backend stay ClusterIP-only." },
{ name: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention, storage, scrape discovery and query budgets are read from config/platform-infra/observability.yaml." },
{ name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector, Tempo and Prometheus stay ClusterIP-only." },
{ name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: "No public ingress is rendered for the first tracing backend." },
{ name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." },
{ name: "allow-all-network-policy", ok: yaml.includes("kind: NetworkPolicy") && yaml.includes("name: allow-all") && yaml.includes(`namespace: ${target.namespace}`), detail: `NetworkPolicy/allow-all is rendered in ${target.namespace}.` },
@@ -76,6 +78,9 @@ export function statusSummary(payload: Record<string, unknown>): Record<string,
const services = objectList(sectionJson(sections, "services"));
const pods = objectList(sectionJson(sections, "pods"));
const events = objectList(sectionJson(sections, "events"));
const metricsDeployments = objectList(optionalSectionJson(sections, "metricsDeployments"));
const metricsServices = objectList(optionalSectionJson(sections, "metricsServices"));
const metricsPods = objectList(optionalSectionJson(sections, "metricsPods"));
const podEvents = latestPodEvents(events);
const probes = Array.isArray(payload.probes) ? payload.probes as Array<Record<string, unknown>> : [];
const readyDeployments = deployments.map((item) => deploymentSummary(item));
@@ -92,6 +97,12 @@ export function statusSummary(payload: Record<string, unknown>): Record<string,
path: item.path,
stderrTail: item.ok === true ? "" : item.stderrTail,
})),
warnings: Array.isArray(payload.warnings) ? payload.warnings : [],
metrics: {
deployments: metricsDeployments.map((item) => deploymentSummary(item)),
services: metricsServices.map((item) => metadataName(item)),
pods: metricsPods.map((item) => podSummary(item, podEvents)),
},
};
}
@@ -106,6 +117,12 @@ export function sectionJson(sections: Record<string, unknown>, name: string): un
return section.json;
}
function optionalSectionJson(sections: Record<string, unknown>, name: string): unknown {
const value = sections[name];
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return (value as Record<string, unknown>).json;
}
export function objectList(value: unknown): Record<string, unknown>[] {
if (typeof value !== "object" || value === null) return [];
const items = (value as Record<string, unknown>).items;
@@ -22,6 +22,7 @@ import {
import type { ObservabilityConfig, ObservabilityTarget } from "./types";
import { tempoService } from "./search-script";
import { imageReference, indent } from "./summary";
import { prometheusManifests } from "./prometheus";
export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
@@ -43,6 +44,7 @@ export function renderManifest(observability: ObservabilityConfig, target: Obser
tempoConfigMap(observability, target),
tempoDeployment(observability, target, tempoImage),
tempoService(observability, target),
...prometheusManifests(observability, target),
].filter((item) => item.trim().length > 0).join("\n---\n");
}
@@ -51,6 +51,7 @@ export interface ObservabilityConfig {
images: {
collector: ImageSpec;
tempo: ImageSpec;
prometheus: ImageSpec;
};
targets: ObservabilityTarget[];
collector: {
@@ -71,6 +72,7 @@ export interface ObservabilityConfig {
otlp: OtlpPorts;
storage: { mode: "emptyDir"; retention: string };
};
metricsBackend: PrometheusConfig;
sampling: { mode: "parentbased_traceidratio"; ratio: number };
instrumentation: {
contextPropagation: string[];
@@ -87,6 +89,31 @@ export interface ObservabilityConfig {
};
}
export interface PrometheusConfig {
type: "prometheus";
enabled: boolean;
targetIds: string[];
deploymentName: string;
serviceName: string;
configMapName: string;
serviceAccountName: string;
clusterRoleName: string;
clusterRoleBindingName: string;
persistentVolumeClaimName: string;
replicas: number;
httpPort: number;
storage: { mode: "persistentVolumeClaim"; size: string; retention: string };
discovery: {
namespaces: string[];
labelSelector: { key: string; value: string };
annotationKeys: { scrape: string; path: string; port: string };
defaultPath: string;
scrapeInterval: string;
scrapeTimeout: string;
};
query: { path: string; timeoutSeconds: number; maxSeries: number; maxResponseBytes: number };
}
export interface ImageSpec {
repository: string;
tag: string;
@@ -165,3 +192,7 @@ export interface DiagnoseCodeAgentOptions extends CommonOptions {
candidateLimit: number;
lookbackMinutes: number;
}
export interface MetricsQueryOptions extends CommonOptions {
query: string | null;
}
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config";
import { renderMirrorBootstrap } from "./platform-infra-gitea-render";
import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
import { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
import { runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code";
const mirror: GiteaMirrorRepository = {
@@ -78,3 +78,64 @@ test("platform bootstrap help advertises the composite entry before low-level ap
expect(usage[0]).toContain("pipelines-as-code bootstrap");
expect(usage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --confirm");
});
test("PaC bootstrap projects typed stages and compact JSON", () => {
const result = {
ok: true,
action: "platform-infra-pipelines-as-code-bootstrap",
mutation: false,
mode: "dry-run",
target: { id: "NC01" },
consumer: { id: "widgets", node: "NC01", lane: "v01" },
repository: { id: "widgets", namespace: "ci" },
mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name },
githubPreflight: { ok: true, mutation: false, repositories: [{ key: mirror.key, code: "github-upstream-available", ok: true }] },
giteaBootstrap: { ok: true, mutation: false, mode: "dry-run", blockers: [] },
pipelinesAsCodeApply: { ok: true, mutation: false, mode: "dry-run", remote: { ok: true, preflight: { repositoryExists: true } } },
};
const projection = projectPacBootstrapResult(result);
expect(projection.ok).toBe(true);
expect(JSON.stringify(projection)).not.toContain("pipelinesAsCodeApply");
expect((projection.stages as Record<string, unknown>[]).map((item) => item.id)).toEqual(["gitea-init", "pac-controller", "tekton-consumer", "gitops-argo"]);
const rendered = renderPacBootstrap(result).renderedText;
expect(rendered).toContain("PRECONDITIONS");
expect(rendered).toContain("yaml-repository-exact-match");
expect(rendered).toContain("confirm:");
});
test("PaC bootstrap returns fix-yaml next for zero YAML matches", () => {
const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: no exact match"));
expect(projectPacBootstrapResult(result)).toBe(result);
expect((result.blockers as Record<string, unknown>[])[0]?.code).toBe("yaml-repository-no-match");
expect(result.next).toEqual({
kind: "fix-yaml",
command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配",
});
});
test("PaC bootstrap returns fix-yaml next for multiple YAML matches", () => {
const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: 2 exact matches"));
expect(result.mutation).toBe(false);
expect((result.blockers as Record<string, unknown>[])[0]?.code).toBe("yaml-repository-multiple-matches");
expect((result.next as Record<string, unknown>).kind).toBe("fix-yaml");
});
test("PaC bootstrap distinguishes GitHub access failure from Gitea and PaC stages", () => {
const projection = projectPacBootstrapResult({
ok: false,
action: "platform-infra-pipelines-as-code-bootstrap",
mutation: false,
mode: "dry-run",
target: { id: "NC01" },
consumer: { id: "widgets", node: "NC01", lane: "v01" },
repository: { id: "widgets", namespace: "ci" },
mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name },
githubPreflight: { ok: false, mutation: false, repositories: [{ key: mirror.key, code: "github-repository-not-found-or-no-access", errorTail: "repository unavailable" }] },
giteaBootstrap: { ok: false, mutation: false, mode: "skipped", githubPreflight: { ok: false, repositories: [{ code: "github-repository-not-found-or-no-access", errorTail: "repository unavailable" }] } },
pipelinesAsCodeApply: { ok: false, mutation: false, mode: "skipped" },
});
expect((projection.blockers as Record<string, unknown>[])).toEqual([{ code: "github-repository-not-found-or-no-access", stage: "github-upstream", reason: "repository unavailable" }]);
expect((projection.preconditions as Record<string, unknown>[])[2]).toMatchObject({ ok: null, code: "gitea-bootstrap-skipped" });
expect((projection.stages as Record<string, unknown>[]).map((item) => item.state)).toEqual(["skipped", "skipped", "skipped", "skipped"]);
expect((projection.next as Record<string, unknown>).kind).toBe("read-only-status");
});
@@ -1,4 +1,5 @@
import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config";
import type { RenderedCliResult } from "./output";
export interface PacBootstrapRepositoryIdentity {
readonly id: string;
@@ -28,6 +29,181 @@ export function resolvePacBootstrapMirrorRepository(
return candidates[0];
}
export function pacBootstrapYamlMatchFailure(
targetId: string,
consumerId: string,
error: unknown,
): Record<string, unknown> {
const reason = error instanceof Error ? error.message : String(error);
const matchCount = reason.includes("no exact match") ? 0 : Number.parseInt(reason.match(/(\d+) exact matches/u)?.[1] ?? "-1", 10);
return {
ok: false,
action: "platform-infra-pipelines-as-code-bootstrap",
mutation: false,
mode: "precondition-failed",
target: { id: targetId },
consumer: { id: consumerId },
preconditions: [
{ id: "yaml-exact-match", ok: false, code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", matchCount, detail: reason },
],
stages: [],
blockers: [{ code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", stage: "yaml-exact-match", reason }],
next: {
kind: "fix-yaml",
command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配",
},
valuesPrinted: false,
};
}
export function projectPacBootstrapResult(result: Record<string, unknown>): Record<string, unknown> {
if (Array.isArray(result.preconditions) && Array.isArray(result.stages) && Array.isArray(result.blockers)) return result;
const target = record(result.target);
const consumer = record(result.consumer);
const repository = record(result.repository);
const mirror = record(result.mirrorRepository);
const githubPreflight = record(result.githubPreflight);
const githubRepository = records(githubPreflight.repositories)[0] ?? {};
const gitea = record(result.giteaBootstrap);
const apply = record(result.pipelinesAsCodeApply);
const remote = record(apply.remote);
const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote);
const dryRun = result.mode === "dry-run";
const repositoryMissing = remote.error === "gitea-repository-missing";
const githubReady = githubPreflight.ok === true;
const giteaReady = gitea.ok === true;
const giteaSkipped = gitea.mode === "skipped";
const applySkipped = apply.mode === "skipped";
const applyReady = apply.ok === true || (dryRun && repositoryMissing);
const next = bootstrapNext(result, blockers);
return {
ok: blockers.length === 0 && giteaReady && applyReady,
action: result.action,
mutation: result.mutation === true,
mode: result.mode,
target: { id: target.id },
consumer: { id: consumer.id, node: consumer.node, lane: consumer.lane },
sourceAuthority: {
upstreamRepository: mirror.upstreamRepository,
upstreamBranch: mirror.upstreamBranch,
github: { ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") },
giteaRepository: `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`,
giteaKey: mirror.key,
valuesPrinted: false,
},
preconditions: [
{ id: "yaml-exact-match", ok: true, code: "yaml-repository-exact-match", matchCount: 1 },
{ id: "github-upstream", ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") },
{ id: "gitea-repository", ok: giteaSkipped ? null : giteaReady, code: giteaSkipped ? "gitea-bootstrap-skipped" : giteaReady ? (dryRun ? "gitea-bootstrap-planned" : "gitea-bootstrap-ready") : "gitea-bootstrap-failed" },
],
stages: [
{ id: "gitea-init", ok: giteaSkipped ? null : giteaReady, state: giteaSkipped ? "skipped" : giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true },
{ id: "pac-controller", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true },
{ id: "tekton-consumer", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true },
{ id: "gitops-argo", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true },
],
repository: { id: repository.id, namespace: repository.namespace },
blockers,
next,
valuesPrinted: false,
};
}
export function renderPacBootstrap(result: Record<string, unknown>): RenderedCliResult {
const projection = projectPacBootstrapResult(result);
const target = record(projection.target);
const consumer = record(projection.consumer);
const source = record(projection.sourceAuthority);
const preconditions = records(projection.preconditions);
const stages = records(projection.stages);
const blockers = records(projection.blockers);
const next = record(projection.next);
const lines = [
"PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP",
...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(projection.mode), boolText(projection.mutation), boolText(projection.ok)]]),
"",
"SOURCE AUTHORITY",
...table(["UPSTREAM", "BRANCH", "GITEA_KEY", "GITEA_REPOSITORY"], [[stringValue(source.upstreamRepository), stringValue(source.upstreamBranch), stringValue(source.giteaKey), stringValue(source.giteaRepository)]]),
"",
"PRECONDITIONS",
...table(["PRECONDITION", "OK", "CODE"], preconditions.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.code)])),
"",
"STAGES",
...table(["STAGE", "OK", "STATE", "MUTATION"], stages.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.state), boolText(item.mutation)])),
...(blockers.length === 0 ? [] : ["", "BLOCKERS", ...blockers.map((item) => ` ${stringValue(item.code)}: ${compactLine(stringValue(item.reason))}`)]),
"",
"NEXT",
` ${stringValue(next.kind)}: ${stringValue(next.command)}`,
];
return { ok: projection.ok === true, command: "platform-infra pipelines-as-code bootstrap", renderedText: lines.join("\n"), contentType: "text/plain", projection };
}
function bootstrapBlockers(githubPreflight: Record<string, unknown>, gitea: Record<string, unknown>, apply: Record<string, unknown>, remote: Record<string, unknown>): Record<string, unknown>[] {
const blockers: Record<string, unknown>[] = [];
const githubRepository = records(githubPreflight.repositories)[0] ?? {};
if (githubPreflight.ok === false) {
blockers.push({ code: stringValue(githubRepository.code, stringValue(githubPreflight.code, "github-upstream-preflight-failed")), stage: "github-upstream", reason: stringValue(githubRepository.errorTail, errorText(githubPreflight)) });
return blockers;
}
for (const code of Array.isArray(gitea.blockers) ? gitea.blockers.map(String) : []) {
blockers.push({ code: code.includes("credential") ? "source-credential-unavailable" : "github-upstream-unavailable", stage: "github-upstream", reason: code });
}
if (gitea.ok !== true && blockers.length === 0) blockers.push({ code: "gitea-bootstrap-failed", stage: "gitea-init", reason: errorText(gitea) });
if (apply.ok !== true && remote.error !== "gitea-repository-missing") {
blockers.push({ code: remote.error === "gitea-credential-unavailable" ? "gitea-credential-unavailable" : "pac-apply-failed", stage: "pac-controller", reason: errorText(remote, apply) });
}
return blockers;
}
function bootstrapNext(result: Record<string, unknown>, blockers: Record<string, unknown>[]): Record<string, unknown> {
const target = record(result.target);
const consumer = record(result.consumer);
const source = record(result.mirrorRepository);
if (blockers.length > 0) {
const yamlFailure = blockers.some((item) => String(item.code).startsWith("yaml-"));
return yamlFailure
? { kind: "fix-yaml", command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配" }
: { kind: "read-only-status", command: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${target.id} --consumer ${consumer.id}` };
}
if (result.mode === "dry-run") return { kind: "confirm", command: `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target ${target.id} --consumer ${consumer.id} --confirm` };
return { kind: "source-pr-merge", command: `合并 ${source.upstreamRepository}@${source.upstreamBranch} 的 GitHub PR,由自动链继续交付` };
}
function errorText(...values: Record<string, unknown>[]): string {
for (const value of values) {
for (const candidate of [value.error, value.stderrTail, record(value.remote).error, record(value.remote).stderrTail]) {
if (typeof candidate === "string" && candidate.length > 0) return candidate;
}
}
return "unknown-bootstrap-failure";
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function records(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.map(record) : [];
}
function stringValue(value: unknown, fallback = "-"): string {
return typeof value === "string" && value.length > 0 ? value : value === undefined || value === null ? fallback : String(value);
}
function boolText(value: unknown): string {
return value === true ? "true" : value === false ? "false" : "-";
}
function compactLine(value: string): string {
return value.replace(/\s+/gu, " ").trim().slice(0, 240) || "-";
}
function table(headers: string[], rows: string[][]): string[] {
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd();
return [format(headers), format(widths.map((width) => "-".repeat(width))), ...rows.map(format)];
}
function repositoryUrlIdentity(value: string): string {
const parsed = new URL(value);
const path = parsed.pathname.replace(/\/+$/u, "");
+22 -43
View File
@@ -28,9 +28,14 @@ import {
} from "./platform-infra-pipelines-as-code-source-artifact";
import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance";
import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac";
import { runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
import { runGiteaMirrorBootstrapPreflight, runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
import { readGiteaConfig } from "./platform-infra-gitea-config";
import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
import {
pacBootstrapYamlMatchFailure,
projectPacBootstrapResult,
renderPacBootstrap,
resolvePacBootstrapMirrorRepository,
} from "./platform-infra-pipelines-as-code-bootstrap";
import { featureConfigSchemaHistoryFields, renderFeatureConfigSchemaLine } from "./platform-infra-pac-feature-config-projection";
const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml");
@@ -256,7 +261,7 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
if (action === "bootstrap") {
const options = parseApplyOptions(args.slice(1));
const result = await bootstrap(config, options);
return options.json || options.full || options.raw ? result : renderBootstrap(result);
return options.full || options.raw ? result : options.json ? projectPacBootstrapResult(result) : renderPacBootstrap(result);
}
if (action === "status") {
const options = parseCommonOptions(args.slice(1));
@@ -975,11 +980,20 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<
throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`);
}
const repository = resolveRepository(pac, consumer.repositoryRef);
const mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository);
let mirror: ReturnType<typeof resolvePacBootstrapMirrorRepository>;
try {
mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository);
} catch (error) {
return pacBootstrapYamlMatchFailure(target.id, consumer.id, error);
}
const githubPreflight = record(await runGiteaMirrorBootstrapPreflight(config, target.id, mirror.key));
if (githubPreflight.ok !== true) {
return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, { ok: false, mutation: false, mode: "skipped", githubPreflight, valuesPrinted: false }, { ok: false, mutation: false, mode: "skipped", valuesPrinted: false });
}
const giteaArgs = ["mirror", "bootstrap", "--target", target.id, "--repo", mirror.key, ...(options.confirm ? ["--confirm"] : []), "--full"];
const giteaBootstrap = record(await runPlatformInfraGiteaCommand(config, giteaArgs));
if (options.confirm && giteaBootstrap.ok !== true) {
return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, {
return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, {
ok: false,
mutation: false,
mode: "skipped",
@@ -988,7 +1002,7 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<
});
}
const pacApply = await apply(config, options);
return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, pacApply);
return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, pacApply);
}
function bootstrapResult(
@@ -997,6 +1011,7 @@ function bootstrapResult(
repository: PacRepository,
mirror: ReturnType<typeof resolvePacBootstrapMirrorRepository>,
options: ApplyOptions,
githubPreflight: Record<string, unknown>,
giteaBootstrap: Record<string, unknown>,
pacApply: Record<string, unknown>,
): Record<string, unknown> {
@@ -1021,6 +1036,7 @@ function bootstrapResult(
repo: mirror.gitea.name,
valuesPrinted: false,
},
githubPreflight,
steps: [
{ id: "gitea-repository", ok: giteaBootstrap.ok === true, mutation: giteaBootstrap.mutation === true, mode: giteaBootstrap.mode, valuesPrinted: false },
{ id: "pac-tekton-gitops", ok: pacReady, mutation: pacApply.mutation === true, mode: pacApply.mode, repositoryMissing, valuesPrinted: false },
@@ -2208,43 +2224,6 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
return rendered(result, "platform-infra pipelines-as-code apply", lines);
}
function renderBootstrap(result: Record<string, unknown>): RenderedCliResult {
const target = record(result.target);
const consumer = record(result.consumer);
const mirror = record(result.mirrorRepository);
const steps = arrayRecords(result.steps);
const pacApply = record(result.pipelinesAsCodeApply);
const pacRemote = record(pacApply.remote);
const giteaBootstrap = record(result.giteaBootstrap);
const next = record(result.next);
const errorValue = [pacRemote.error, giteaBootstrap.error, pacApply.error]
.find((value): value is string => typeof value === "string" && value.length > 0) ?? "";
const error = errorValue.length === 0 ? "" : compactLine(errorValue);
const lines = [
"PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP",
...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(result.mode), boolText(result.mutation), boolText(result.ok)]]),
"",
"SOURCE AUTHORITY",
...table(["GITEA_KEY", "GITEA_REPOSITORY", "UPSTREAM", "BRANCH"], [[stringValue(mirror.key), `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`, stringValue(mirror.upstreamRepository), stringValue(mirror.upstreamBranch)]]),
"",
"STEPS",
...table(["STEP", "OK", "MODE", "MUTATION", "DETAIL"], steps.map((step) => [
stringValue(step.id),
boolText(step.ok),
stringValue(step.mode),
boolText(step.mutation),
step.repositoryMissing === true ? "repository-will-be-created-on-confirm" : "ready",
])),
...(error.length === 0 ? [] : ["", `ERROR ${error}`]),
"",
"NEXT",
...(result.mode === "dry-run"
? [` confirm: ${stringValue(next.confirm)}`, ` boundary: ${stringValue(next.boundary)}`]
: [` delivery: ${stringValue(next.deliveryTrigger)}`, ` investigate: ${stringValue(next.investigate)}`]),
];
return rendered(result, "platform-infra pipelines-as-code bootstrap", lines);
}
export function renderStatus(result: Record<string, unknown>): RenderedCliResult {
const summary = record(result.summary);
const consumer = record(result.consumer);