Merge remote-tracking branch 'origin/master' into feat/2010-hwlab-release-production-lane

This commit is contained in:
Codex
2026-07-15 06:27:08 +02:00
31 changed files with 1105 additions and 183 deletions
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { projectLogicalDumps } from "./platform-db-postgres-backups";
import { readPostgresLogicalDumps, renderPostgresRemoteRunnerForTest } from "./platform-db";
describe("PK01 PostgreSQL logical dumps", () => {
test("parses independent Sub2API and PikaOA declarations", () => {
const logicalDumps = readPostgresLogicalDumps("config/platform-db/postgres-pk01.yaml");
expect(logicalDumps).toHaveLength(2);
expect(logicalDumps.map((item) => item.database)).toEqual(["sub2api", "pikaoa"]);
expect(logicalDumps[0]).toMatchObject({
scriptPath: "/usr/local/sbin/unidesk-pk01-sub2api-pgdump.sh",
serviceName: "unidesk-pk01-sub2api-pgdump.service",
timerName: "unidesk-pk01-sub2api-pgdump.timer",
schedule: "*-*-* 03:17:00",
retentionDays: 14,
});
expect(logicalDumps[1]).toMatchObject({
destination: { path: "/var/backups/unidesk/platform-db/pk01/pikaoa" },
schedule: "*-*-* 03:37:00",
retentionDays: 14,
});
});
test("renders YAML-driven backup resources without Sub2API constants", () => {
const runner = renderPostgresRemoteRunnerForTest();
expect(runner).toContain('data["backup"]["logicalDumps"][index]');
expect(runner).toContain("backup.logicalDumps.$backup_index.serviceName");
expect(runner).toContain("backup.logicalDumps.$backup_index.timerName");
expect(runner).not.toContain("unidesk-pk01-sub2api-pgdump.service");
expect(runner).not.toContain("OnCalendar=*-*-* 03:17:00");
});
test("reports stale or missing evidence as warnings", () => {
const [declaration] = readPostgresLogicalDumps("config/platform-db/postgres-pk01.yaml");
const [projection] = projectLogicalDumps([declaration], [{
database: declaration.database,
databaseExists: true,
scriptPath: declaration.scriptPath,
scriptExists: true,
serviceName: declaration.serviceName,
serviceResult: "success",
serviceExitStatus: 0,
lastSuccessAt: null,
timerName: declaration.timerName,
timerActive: true,
timerEnabled: true,
nextRunAt: null,
latestNonEmptyDump: null,
}]);
expect(projection.recoverability.recoverable).toBeFalse();
expect(projection.warnings).toContain("backup-recent-success-missing");
expect(projection.warnings).toContain("backup-latest-non-empty-dump-missing");
});
});
@@ -0,0 +1,90 @@
export interface LogicalDumpConfig {
enabled: boolean;
database: string;
scriptPath: string;
serviceName: string;
timerName: string;
schedule: string;
retentionDays: number;
warningAfterHours: number;
destination: { type: string; path: string };
pgDump: { host: string; format: "custom" };
encryption: { enabled: boolean };
}
export interface LogicalDumpRuntimeFact {
database: string;
databaseExists: boolean;
scriptPath: string;
scriptExists: boolean;
serviceName: string;
serviceResult: string | null;
serviceExitStatus: number | null;
lastSuccessAt: string | null;
timerName: string;
timerActive: boolean;
timerEnabled: boolean;
nextRunAt: string | null;
latestNonEmptyDump: {
path: string;
bytes: number;
modifiedAt: string;
} | null;
}
export interface LogicalDumpProjection extends LogicalDumpConfig {
observed: LogicalDumpRuntimeFact | null;
recoverability: {
recoverable: boolean;
latestNonEmptyDump: LogicalDumpRuntimeFact["latestNonEmptyDump"];
};
warnings: string[];
}
export function projectLogicalDumps(
declarations: LogicalDumpConfig[],
facts: LogicalDumpRuntimeFact[] | null,
observedAt = Date.now(),
): LogicalDumpProjection[] {
return declarations.map((declaration) => {
const observed = facts?.find((item) => item.database === declaration.database && item.timerName === declaration.timerName) ?? null;
const warnings: string[] = [];
if (!declaration.enabled) return projection(declaration, observed, warnings);
if (observed === null) {
warnings.push("backup-observation-unavailable");
return projection(declaration, observed, warnings);
}
if (!observed.databaseExists) warnings.push("backup-database-missing");
if (!observed.scriptExists) warnings.push("backup-script-missing");
if (!observed.timerEnabled) warnings.push("backup-timer-disabled");
if (!observed.timerActive) warnings.push("backup-timer-inactive");
if (observed.lastSuccessAt === null) warnings.push("backup-recent-success-missing");
if (observed.latestNonEmptyDump === null) {
warnings.push("backup-latest-non-empty-dump-missing");
} else {
const modifiedAt = Date.parse(observed.latestNonEmptyDump.modifiedAt);
if (!Number.isFinite(modifiedAt) || observedAt - modifiedAt > declaration.warningAfterHours * 60 * 60 * 1000) {
warnings.push("backup-latest-dump-stale");
}
}
if (observed.serviceResult !== null && observed.serviceResult !== "success") warnings.push("backup-last-run-failed");
if (observed.serviceExitStatus !== null && observed.serviceExitStatus !== 0) warnings.push("backup-last-run-nonzero");
return projection(declaration, observed, warnings);
});
}
function projection(
declaration: LogicalDumpConfig,
observed: LogicalDumpRuntimeFact | null,
warnings: string[],
): LogicalDumpProjection {
return {
...declaration,
observed,
recoverability: {
recoverable: observed?.databaseExists === true && observed.latestNonEmptyDump !== null,
latestNonEmptyDump: observed?.latestNonEmptyDump ?? null,
},
warnings,
};
}
+221 -65
View File
@@ -8,6 +8,11 @@ import { startJob } from "./jobs";
import { MONITOR_CENTRAL_MIGRATIONS, MONITOR_CENTRAL_SCHEMA_VERSION } from "./monitor-central-persistence";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
import { compactText, fingerprintEnvValues as fingerprintValues, parseEnvFile, parseJsonOutput } from "./platform-infra-ops-library";
import {
projectLogicalDumps,
type LogicalDumpConfig,
type LogicalDumpRuntimeFact,
} from "./platform-db-postgres-backups";
const defaultConfigPath = "config/platform-db/postgres-pk01.yaml";
const managedHbaStart = "# BEGIN unidesk managed pk01-platform-postgres";
@@ -138,16 +143,7 @@ interface PostgresHostConfig {
exports: {
connectionStrings: ConnectionStringExportConfig[];
};
backup: {
logicalDump: {
enabled: boolean;
schedule: string;
database: string;
retentionDays: number;
destination: { type: string; path: string };
encryption: { enabled: boolean };
};
};
backup: { logicalDumps: LogicalDumpConfig[] };
managedOperations?: { monitorReset: MonitorResetConfig };
}
@@ -272,6 +268,7 @@ interface RemoteFacts {
error: string | null;
}>;
};
backup: { logicalDumps: LogicalDumpRuntimeFact[] };
raw?: Record<string, unknown>;
}
@@ -442,6 +439,7 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise<
const facts = remote.parsed;
const checks = facts === null ? [] : desiredChecks(pg, facts, secrets);
const desired = desiredSummary(pg, secrets, facts);
const logicalDumps = projectLogicalDumps(pg.backup.logicalDumps, facts?.backup.logicalDumps ?? null);
const result = {
ok: remote.capture.exitCode === 0 && facts !== null && secrets.ok,
action: "platform-db-postgres-plan",
@@ -450,6 +448,8 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise<
secrets: secretSummary(secrets),
remoteFacts: facts,
desired,
logicalDumps,
warnings: logicalDumps.flatMap((item) => item.warnings.map((warning) => ({ database: item.database, warning }))),
checks,
next: {
apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`,
@@ -491,6 +491,7 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis
...(endpointHealthy ? [] : [controllerConnection.attempted ? "connection-host-probe-failed" : "connection-host-probe-unavailable"]),
];
const exportTargets = inspectExportTargets(pg);
const logicalDumps = projectLogicalDumps(pg.backup.logicalDumps, facts?.backup.logicalDumps ?? null);
const result = {
ok: remote.capture.exitCode === 0 && facts !== null && deploymentHealthy && secrets.ok,
action: "platform-db-postgres-status",
@@ -526,6 +527,8 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis
dnsRequiredBeforeSub2ApiCutover: false,
dnsDisposition: facts.network.dns.resolves ? "optional-alias-resolves" : "optional-alias-unresolved",
},
logicalDumps,
warnings: logicalDumps.flatMap((item) => item.warnings.map((warning) => ({ database: item.database, warning }))),
remoteFacts: facts,
secrets: secretSummary(secrets),
exports: exportTargets,
@@ -668,9 +671,6 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
const exportsConfig = objectField(parsed, "exports", configPath);
const backup = objectField(parsed, "backup", configPath);
const managedOperations = parsed.managedOperations === undefined ? null : objectField(parsed, "managedOperations", configPath);
const logicalDump = objectField(backup, "logicalDump", `${configPath}.backup`);
const destination = objectField(logicalDump, "destination", `${configPath}.backup.logicalDump`);
const encryption = objectField(logicalDump, "encryption", `${configPath}.backup.logicalDump`);
const manager = stringField(postgresPackage, "manager", `${configPath}.postgres.package`);
if (manager !== "apt") throw new Error(`${configPath}.postgres.package.manager must be apt`);
const packageVersion = stringField(postgresPackage, "version", `${configPath}.postgres.package`);
@@ -697,6 +697,23 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
databaseNames.add(database.name);
}
const connectionStrings = arrayOfRecords(exportsConfig.connectionStrings, `${configPath}.exports.connectionStrings`).map((item, index) => connectionStringExport(item, `${configPath}.exports.connectionStrings[${index}]`));
const logicalDumps = arrayOfRecords(backup.logicalDumps, `${configPath}.backup.logicalDumps`).map((item, index) => logicalDumpConfig(item, `${configPath}.backup.logicalDumps[${index}]`));
if (logicalDumps.length === 0) throw new Error(`${configPath}.backup.logicalDumps must declare at least one logical dump`);
const logicalDumpKeys = new Set<string>();
for (const logicalDump of logicalDumps) {
if (!databaseNames.has(logicalDump.database)) throw new Error(`${configPath}.backup.logicalDumps.${logicalDump.database}.database must reference a declared database`);
for (const [kind, value] of [
["database", logicalDump.database],
["scriptPath", logicalDump.scriptPath],
["serviceName", logicalDump.serviceName],
["timerName", logicalDump.timerName],
["destination.path", logicalDump.destination.path],
] as const) {
const key = `${kind}:${value}`;
if (logicalDumpKeys.has(key)) throw new Error(`${configPath}.backup.logicalDumps contains duplicate ${kind} ${value}`);
logicalDumpKeys.add(key);
}
}
const monitorReset = managedOperations === null ? null : monitorResetConfig(objectField(managedOperations, "monitorReset", `${configPath}.managedOperations`), `${configPath}.managedOperations.monitorReset`);
if (monitorReset !== null) {
const database = databases.find((item) => item.name === monitorReset.database);
@@ -800,25 +817,53 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
},
objects: { roles, databases },
exports: { connectionStrings },
backup: {
logicalDump: {
enabled: booleanField(logicalDump, "enabled", `${configPath}.backup.logicalDump`),
schedule: stringField(logicalDump, "schedule", `${configPath}.backup.logicalDump`),
database: stringField(logicalDump, "database", `${configPath}.backup.logicalDump`),
retentionDays: integerField(logicalDump, "retentionDays", `${configPath}.backup.logicalDump`),
destination: {
type: stringField(destination, "type", `${configPath}.backup.logicalDump.destination`),
path: absolutePathField(destination, "path", `${configPath}.backup.logicalDump.destination`),
},
encryption: {
enabled: booleanField(encryption, "enabled", `${configPath}.backup.logicalDump.encryption`),
},
},
},
backup: { logicalDumps },
...(monitorReset === null ? {} : { managedOperations: { monitorReset } }),
};
}
export function readPostgresLogicalDumps(pathArg: string): LogicalDumpConfig[] {
return readPostgresHostConfig(pathArg).backup.logicalDumps;
}
function logicalDumpConfig(item: Record<string, unknown>, path: string): LogicalDumpConfig {
const destination = objectField(item, "destination", path);
const pgDump = objectField(item, "pgDump", path);
const encryption = objectField(item, "encryption", path);
const serviceName = systemdUnitName(stringField(item, "serviceName", path), `${path}.serviceName`, ".service");
const timerName = systemdUnitName(stringField(item, "timerName", path), `${path}.timerName`, ".timer");
const format = stringField(pgDump, "format", `${path}.pgDump`);
if (format !== "custom") throw new Error(`${path}.pgDump.format must be custom`);
const retentionDays = integerField(item, "retentionDays", path);
const warningAfterHours = integerField(item, "warningAfterHours", path);
if (retentionDays < 1) throw new Error(`${path}.retentionDays must be positive`);
if (warningAfterHours < 1) throw new Error(`${path}.warningAfterHours must be positive`);
return {
enabled: booleanField(item, "enabled", path),
database: pgIdentifier(stringField(item, "database", path), `${path}.database`),
scriptPath: absolutePathField(item, "scriptPath", path),
serviceName,
timerName,
schedule: stringField(item, "schedule", path),
retentionDays,
warningAfterHours,
destination: {
type: stringField(destination, "type", `${path}.destination`),
path: absolutePathField(destination, "path", `${path}.destination`),
},
pgDump: {
host: stringField(pgDump, "host", `${path}.pgDump`),
format,
},
encryption: { enabled: booleanField(encryption, "enabled", `${path}.encryption`) },
};
}
function systemdUnitName(value: string, path: string, suffix: ".service" | ".timer"): string {
if (!/^[A-Za-z0-9_.@-]+$/u.test(value) || !value.endsWith(suffix)) throw new Error(`${path} must be a safe ${suffix} unit name`);
return value;
}
function monitorResetConfig(item: Record<string, unknown>, path: string): MonitorResetConfig {
return {
database: pgIdentifier(stringField(item, "database", path), `${path}.database`),
@@ -1080,6 +1125,7 @@ function configSummary(pg: PostgresHostConfig): Record<string, unknown> {
roles: pg.objects.roles.map((role) => ({ name: role.name, login: role.login })),
databases: pg.objects.databases.map((database) => ({ name: database.name, owner: database.owner })),
},
logicalDumps: pg.backup.logicalDumps,
noK3s: true,
};
}
@@ -1142,6 +1188,7 @@ function compactPlan(
key: item.writeToSecretSource.key,
consumerScopes: item.consumers.map((consumer) => consumer.scope),
})),
logicalDumps: compactLogicalDumps(pg, facts),
checks: {
ok: checks.every((check) => check.ok === true),
passed: checks.filter((check) => check.ok === true).length,
@@ -1276,6 +1323,7 @@ function compactStatus(
fingerprint: item.fingerprint,
}),
}),
logicalDumps: compactLogicalDumps(pg, facts),
remote: compactPlanCapture(capture),
next: {
plan: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath}`,
@@ -1292,6 +1340,28 @@ function compactStatus(
};
}
function compactLogicalDumps(pg: PostgresHostConfig, facts: RemoteFacts | null): Array<Record<string, unknown>> {
return projectLogicalDumps(pg.backup.logicalDumps, facts?.backup.logicalDumps ?? null).map((item) => ({
database: item.database,
enabled: item.enabled,
scriptPath: item.scriptPath,
serviceName: item.serviceName,
timerName: item.timerName,
directory: item.destination.path,
schedule: item.schedule,
retentionDays: item.retentionDays,
timer: item.observed === null ? null : {
active: item.observed.timerActive,
enabled: item.observed.timerEnabled,
nextRunAt: item.observed.nextRunAt,
},
lastSuccessAt: item.observed?.lastSuccessAt ?? null,
latestNonEmptyDump: item.recoverability.latestNonEmptyDump,
recoverable: item.recoverability.recoverable,
warnings: item.warnings,
}));
}
function compactExportSecrets(
pg: PostgresHostConfig,
localState: { inspection: SecretInspection; summary: Record<string, unknown>; exports: Array<Record<string, unknown>> },
@@ -1773,7 +1843,7 @@ function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts
consumers: item.consumers,
valuesPrinted: false,
})),
backup: pg.backup.logicalDump,
backup: { logicalDumps: projectLogicalDumps(pg.backup.logicalDumps, facts?.backup.logicalDumps ?? null) },
};
}
@@ -1886,6 +1956,20 @@ function factsScript(pg: PostgresHostConfig, probes: Array<{ user: string; passw
`printf '%s' ${shellQuote(appHost)} >"$tmp/appConnHost.${index}"`,
].join("\n");
}).join("\n");
const logicalDumpCommands = pg.backup.logicalDumps.map((logicalDump, index) => {
const encoded = Buffer.from(JSON.stringify(logicalDump), "utf8").toString("base64");
return [
`printf '%s' '${encoded}' | base64 -d >"$tmp/logicalDump.${index}.json"`,
`[ -f ${shellQuote(logicalDump.scriptPath)} ]; printf '%s' "$?" >"$tmp/logicalDumpScriptRc.${index}"`,
`systemctl is-active ${shellQuote(logicalDump.timerName)} >"$tmp/logicalDumpTimerActive.${index}" 2>/dev/null`,
`systemctl is-enabled ${shellQuote(logicalDump.timerName)} >"$tmp/logicalDumpTimerEnabled.${index}" 2>/dev/null`,
`systemctl show ${shellQuote(logicalDump.timerName)} -p NextElapseUSecRealtime --value >"$tmp/logicalDumpNextRun.${index}" 2>/dev/null`,
`systemctl show ${shellQuote(logicalDump.serviceName)} -p Result --value >"$tmp/logicalDumpServiceResult.${index}" 2>/dev/null`,
`systemctl show ${shellQuote(logicalDump.serviceName)} -p ExecMainStatus --value >"$tmp/logicalDumpServiceExitStatus.${index}" 2>/dev/null`,
`systemctl show ${shellQuote(logicalDump.serviceName)} -p InactiveExitTimestamp --value >"$tmp/logicalDumpLastSuccess.${index}" 2>/dev/null`,
`find ${shellQuote(logicalDump.destination.path)} -type f -name ${shellQuote(`${logicalDump.database}-*.dump`)} -size +0c -printf '%T@\t%s\t%p\n' 2>/dev/null | sort -nr | head -n 1 >"$tmp/logicalDumpLatest.${index}"`,
].join("\n");
}).join("\n");
return `
set +e
tmp="$(mktemp -d)"
@@ -1939,8 +2023,9 @@ if command -v psql >/dev/null 2>&1; then
:
${probeCommands}
fi
${logicalDumpCommands}
python3 - "$tmp" "${release}" <<'PY'
import json, os, sys
import datetime, json, os, sys
tmp, release = sys.argv[1], sys.argv[2]
def text(name):
try:
@@ -1968,6 +2053,44 @@ existing_roles = set_from_lines("rolesExisting")
existing_databases = set_from_lines("databasesExisting")
roles = [{"name": name, "exists": name in existing_roles} for name in expected_roles]
databases = [{"name": item["name"], "owner": item["owner"], "exists": item["name"] in existing_databases} for item in expected_databases]
logical_dumps = []
index = 0
while True:
declaration = load_json(f"logicalDump.{index}.json", None)
if declaration is None:
break
latest_fields = text(f"logicalDumpLatest.{index}").split("\t", 2)
latest = None
if len(latest_fields) == 3:
try:
latest = {
"path": latest_fields[2],
"bytes": int(latest_fields[1]),
"modifiedAt": datetime.datetime.fromtimestamp(float(latest_fields[0]), datetime.timezone.utc).isoformat(),
}
except (TypeError, ValueError):
latest = None
service_result = text(f"logicalDumpServiceResult.{index}") or None
exit_status = int_or_none(f"logicalDumpServiceExitStatus.{index}")
last_success = text(f"logicalDumpLastSuccess.{index}") or None
if service_result != "success" or exit_status not in {None, 0}:
last_success = None
logical_dumps.append({
"database": declaration["database"],
"databaseExists": declaration["database"] in existing_databases,
"scriptPath": declaration["scriptPath"],
"scriptExists": yes_file(f"logicalDumpScriptRc.{index}"),
"serviceName": declaration["serviceName"],
"serviceResult": service_result,
"serviceExitStatus": exit_status,
"lastSuccessAt": last_success,
"timerName": declaration["timerName"],
"timerActive": text(f"logicalDumpTimerActive.{index}") == "active",
"timerEnabled": text(f"logicalDumpTimerEnabled.{index}") == "enabled",
"nextRunAt": text(f"logicalDumpNextRun.{index}") or None,
"latestNonEmptyDump": latest,
})
index += 1
app_connections = []
index = 0
while True:
@@ -2045,6 +2168,7 @@ payload = {
"appConnectionError": primary_conn.get("error"),
"appConnections": app_connections,
},
"backup": {"logicalDumps": logical_dumps},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
PY
@@ -2099,7 +2223,7 @@ function remoteApplyPayload(pg: PostgresHostConfig, secrets: SecretInspection):
databases: pg.objects.databases,
role: roles[0],
database: pg.objects.databases[0],
backup: pg.backup.logicalDump,
backup: pg.backup,
hbaMarkers: { start: managedHbaStart, end: managedHbaEnd },
};
}
@@ -2222,7 +2346,7 @@ import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
value = data
for part in sys.argv[2].split("."):
value = value[part]
value = value[int(part)] if isinstance(value, list) else value[part]
if isinstance(value, bool):
print("true" if value else "false")
else:
@@ -2232,10 +2356,15 @@ PY
render_file() {
name="$1"
python3 - "$payload" "$name" <<'PY'
index=""
if [ "$#" -ge 2 ]; then
index="$2"
fi
python3 - "$payload" "$name" "$index" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
name = sys.argv[2]
index = int(sys.argv[3]) if sys.argv[3] else None
if name == "pg_hba":
markers = data["hbaMarkers"]
print(markers["start"])
@@ -2268,7 +2397,7 @@ elif name == "databases_tsv":
for database in data["databases"]:
print("\t".join([database["name"], database["owner"], database["encoding"], database["locale"]]))
elif name == "backup_script":
backup = data["backup"]
backup = data["backup"]["logicalDumps"][index]
db = backup["database"]
database = next((item for item in data["databases"] if item["name"] == db), None)
if database is None:
@@ -2277,6 +2406,7 @@ elif name == "backup_script":
if role is None:
raise SystemExit(f"backup owner {database['owner']} has no declared role")
role_name = role["name"]
host = backup["pgDump"]["host"]
port = data["network"]["port"]
dest = backup["destination"]["path"]
retention = backup["retentionDays"]
@@ -2286,8 +2416,27 @@ elif name == "backup_script":
print(f"dest='{dest}'")
print("mkdir -p \"$dest\"")
print("ts=$(date +%Y%m%dT%H%M%S)")
print(f"PGPASSWORD='{password}' /usr/lib/postgresql/{data['pgVersion']}/bin/pg_dump -h 127.0.0.1 -p {port} -U {role_name} -d {db} --format=custom --file \"$dest/{db}-$ts.dump\"")
print(f"PGPASSWORD='{password}' /usr/lib/postgresql/{data['pgVersion']}/bin/pg_dump -h {host} -p {port} -U {role_name} -d {db} --format={backup['pgDump']['format']} --file \"$dest/{db}-$ts.dump\"")
print(f"find \"$dest\" -type f -name '{db}-*.dump' -mtime +{retention} -delete")
elif name == "backup_service":
backup = data["backup"]["logicalDumps"][index]
print("[Unit]")
print(f"Description=UniDesk PK01 {backup['database']} PostgreSQL logical backup")
print("")
print("[Service]")
print("Type=oneshot")
print(f"ExecStart={backup['scriptPath']}")
elif name == "backup_timer":
backup = data["backup"]["logicalDumps"][index]
print("[Unit]")
print(f"Description=Scheduled UniDesk PK01 {backup['database']} PostgreSQL logical backup")
print("")
print("[Timer]")
print(f"OnCalendar={backup['schedule']}")
print("Persistent=true")
print("")
print("[Install]")
print("WantedBy=timers.target")
PY
}
@@ -2307,8 +2456,6 @@ SWAP_PATH="$(json_get node.swap.path)"
SWAP_SIZE="$(json_get node.swap.size)"
CONFIG_DIR="$(json_get paths.configDir)"
DATA_DIR="$(json_get paths.dataDir)"
BACKUP_ENABLED="$(json_get backup.enabled)"
BACKUP_PATH="$(json_get backup.destination.path)"
TLS_ENABLED="$(json_get tls.enabled)"
TLS_COMMON_NAME="$(json_get tls.commonName)"
TLS_CERT_FILE="$(json_get tls.certFile)"
@@ -2408,34 +2555,39 @@ while IFS="$(printf '\t')" read -r DB_NAME DB_OWNER DB_ENCODING DB_LOCALE; do
fi
done < "$databases_tsv"
if [ "$BACKUP_ENABLED" = "true" ]; then
write_state running configure-backup
mkdir -p "$BACKUP_PATH"
backup_script="/usr/local/sbin/unidesk-pk01-sub2api-pgdump.sh"
render_file backup_script > "$backup_script"
chmod 700 "$backup_script"
cat > /etc/systemd/system/unidesk-pk01-sub2api-pgdump.service <<SERVICE
[Unit]
Description=UniDesk PK01 Sub2API PostgreSQL logical backup
[Service]
Type=oneshot
ExecStart=$backup_script
SERVICE
cat > /etc/systemd/system/unidesk-pk01-sub2api-pgdump.timer <<TIMER
[Unit]
Description=Daily UniDesk PK01 Sub2API PostgreSQL logical backup
[Timer]
OnCalendar=*-*-* 03:17:00
Persistent=true
[Install]
WantedBy=timers.target
TIMER
systemctl daemon-reload
systemctl enable --now unidesk-pk01-sub2api-pgdump.timer
fi
write_state running configure-backups
BACKUP_COUNT="$(python3 - "$payload" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
print(len(data["backup"]["logicalDumps"]))
PY
)"
backup_index=0
while [ "$backup_index" -lt "$BACKUP_COUNT" ]; do
enabled="$(json_get backup.logicalDumps.$backup_index.enabled)"
if [ "$enabled" = "true" ]; then
backup_path="$(json_get backup.logicalDumps.$backup_index.destination.path)"
backup_script="$(json_get backup.logicalDumps.$backup_index.scriptPath)"
backup_service="$(json_get backup.logicalDumps.$backup_index.serviceName)"
backup_timer="$(json_get backup.logicalDumps.$backup_index.timerName)"
mkdir -p "$backup_path"
render_file backup_script "$backup_index" > "$backup_script"
chmod 700 "$backup_script"
render_file backup_service "$backup_index" > "/etc/systemd/system/$backup_service"
render_file backup_timer "$backup_index" > "/etc/systemd/system/$backup_timer"
fi
backup_index=$((backup_index + 1))
done
systemctl daemon-reload
backup_index=0
while [ "$backup_index" -lt "$BACKUP_COUNT" ]; do
enabled="$(json_get backup.logicalDumps.$backup_index.enabled)"
if [ "$enabled" = "true" ]; then
backup_timer="$(json_get backup.logicalDumps.$backup_index.timerName)"
systemctl enable --now "$backup_timer"
fi
backup_index=$((backup_index + 1))
done
write_state running final-check
runuser -u postgres -- psql -Atqc "SHOW server_version;" >/dev/null
@@ -2450,6 +2602,10 @@ exit 0
`;
}
export function renderPostgresRemoteRunnerForTest(): string {
return remoteRunnerScript();
}
async function waitRemoteApplyJob(config: UniDeskConfig, pg: PostgresHostConfig, remoteJobId: string): Promise<Record<string, unknown>> {
const observations: Array<Record<string, unknown>> = [];
const maxPolls = 240;
@@ -0,0 +1,202 @@
import { readFileSync } from "node:fs";
import type { UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { renderTable } from "./render";
import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
import { announcementsScript } from "./remote-scripts";
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target";
type AnnouncementAction = "list" | "get" | "create";
interface AnnouncementOptions {
action: AnnouncementAction;
id: number | null;
page: number;
pageSize: number;
status: string | null;
search: string | null;
sortBy: string;
sortOrder: "asc" | "desc";
title: string | null;
content: string | null;
announcementStatus: "draft" | "active" | "archived";
notifyMode: "silent" | "popup";
targeting: Record<string, unknown>;
startsAt: number | null;
endsAt: number | null;
confirm: boolean;
targetId: string;
json: boolean;
}
export async function codexPoolAnnouncements(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
if (args[0] === "help" || args.includes("--help")) return renderAnnouncementsHelp();
const options = parseAnnouncementOptions(args);
const target = codexPoolRuntimeTarget(options.targetId);
const payload = announcementPayload(options);
if (options.action === "create" && !options.confirm) {
const response = {
ok: true,
action: "platform-infra-sub2api-codex-pool-announcements",
target: targetSummary(target),
request: payload,
report: {
ok: true,
mode: "dry-run",
mutation: false,
writeAttempted: 0,
announcement: payload.announcement,
valuesPrinted: false,
},
valuesPrinted: false,
};
return options.json ? renderAnnouncementsJson(response) : renderAnnouncements(response);
}
const remote = await runRemoteCodexPoolScript(config, "announcements", announcementsScript(payload, target), target);
const report = parseJsonOutput(remote.stdout);
const ok = remote.exitCode === 0 && boolField(report, "ok", false);
const response = {
ok,
action: "platform-infra-sub2api-codex-pool-announcements",
target: targetSummary(target),
request: payload,
report,
remote: compactCapture(remote, { full: !ok || report === null }),
valuesPrinted: false,
};
return options.json ? renderAnnouncementsJson(response) : renderAnnouncements(response);
}
function parseAnnouncementOptions(args: string[]): AnnouncementOptions {
const [actionRaw = "list", ...rest] = args;
if (!(["list", "get", "create"] as string[]).includes(actionRaw)) throw new Error("announcements usage: list|get|create [options]");
const action = actionRaw as AnnouncementAction;
let id: number | null = null;
let page = 1;
let pageSize = 20;
let status: string | null = null;
let search: string | null = null;
let sortBy = "created_at";
let sortOrder: "asc" | "desc" = "desc";
let title: string | null = null;
let content: string | null = null;
let contentFile: string | null = null;
let announcementStatus: "draft" | "active" | "archived" = "draft";
let notifyMode: "silent" | "popup" = "silent";
let targeting: Record<string, unknown> = {};
let startsAt: number | null = null;
let endsAt: number | null = null;
let confirm = false;
let targetId = defaultCodexPoolRuntimeTargetId();
let json = false;
const readValue = (index: number, option: string): [string, number] => {
const value = rest[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${option} requires a value`);
return [value, index + 1];
};
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index]!;
if (arg === "--confirm") confirm = true;
else if (arg === "--json") json = true;
else if (arg === "--id") { let value: string; [value, index] = readValue(index, arg); id = positiveInteger(value, arg); }
else if (arg.startsWith("--id=")) id = positiveInteger(arg.slice(5), "--id");
else if (arg === "--page") { let value: string; [value, index] = readValue(index, arg); page = positiveInteger(value, arg); }
else if (arg.startsWith("--page=")) page = positiveInteger(arg.slice(7), "--page");
else if (arg === "--page-size") { let value: string; [value, index] = readValue(index, arg); pageSize = boundedPageSize(value); }
else if (arg.startsWith("--page-size=")) pageSize = boundedPageSize(arg.slice(12));
else if (arg === "--status") [status, index] = readValue(index, arg);
else if (arg.startsWith("--status=")) status = arg.slice(9);
else if (arg === "--search") [search, index] = readValue(index, arg);
else if (arg.startsWith("--search=")) search = arg.slice(9);
else if (arg === "--sort-by") [sortBy, index] = readValue(index, arg);
else if (arg.startsWith("--sort-by=")) sortBy = arg.slice(10);
else if (arg === "--sort-order") { let value: string; [value, index] = readValue(index, arg); sortOrder = parseSortOrder(value); }
else if (arg.startsWith("--sort-order=")) sortOrder = parseSortOrder(arg.slice(13));
else if (arg === "--title") [title, index] = readValue(index, arg);
else if (arg.startsWith("--title=")) title = arg.slice(8);
else if (arg === "--content") [content, index] = readValue(index, arg);
else if (arg.startsWith("--content=")) content = arg.slice(10);
else if (arg === "--content-file") [contentFile, index] = readValue(index, arg);
else if (arg.startsWith("--content-file=")) contentFile = arg.slice(15);
else if (arg === "--announcement-status") { let value: string; [value, index] = readValue(index, arg); announcementStatus = parseAnnouncementStatus(value); }
else if (arg.startsWith("--announcement-status=")) announcementStatus = parseAnnouncementStatus(arg.slice(22));
else if (arg === "--notify-mode") { let value: string; [value, index] = readValue(index, arg); notifyMode = parseNotifyMode(value); }
else if (arg.startsWith("--notify-mode=")) notifyMode = parseNotifyMode(arg.slice(14));
else if (arg === "--targeting-json") { let value: string; [value, index] = readValue(index, arg); targeting = parseTargeting(value); }
else if (arg.startsWith("--targeting-json=")) targeting = parseTargeting(arg.slice(17));
else if (arg === "--starts-at") { let value: string; [value, index] = readValue(index, arg); startsAt = parseTimestamp(value, arg); }
else if (arg.startsWith("--starts-at=")) startsAt = parseTimestamp(arg.slice(12), "--starts-at");
else if (arg === "--ends-at") { let value: string; [value, index] = readValue(index, arg); endsAt = parseTimestamp(value, arg); }
else if (arg.startsWith("--ends-at=")) endsAt = parseTimestamp(arg.slice(10), "--ends-at");
else if (arg === "--target") [targetId, index] = readValue(index, arg);
else if (arg.startsWith("--target=")) targetId = arg.slice(9);
else throw new Error(`unsupported announcements option: ${arg}`);
}
if (action !== "create" && confirm) throw new Error("--confirm is only valid for announcements create");
if (action === "get" && id === null) throw new Error("announcements get requires --id");
if (action === "create") {
if ((content === null) === (contentFile === null)) throw new Error("announcements create requires exactly one of --content or --content-file");
if (contentFile !== null) content = readFileSync(contentFile, "utf8");
title = title?.trim() ?? null;
content = content?.trim() ?? null;
if (title === null || title.length === 0 || title.length > 200) throw new Error("announcements create requires --title up to 200 characters");
if (content === null || content.length === 0) throw new Error("announcements create requires non-empty Markdown content");
if (startsAt !== null && endsAt !== null && startsAt >= endsAt) throw new Error("--starts-at must be earlier than --ends-at");
}
return { action, id, page, pageSize, status, search, sortBy, sortOrder, title, content, announcementStatus, notifyMode, targeting, startsAt, endsAt, confirm, targetId, json };
}
function announcementPayload(options: AnnouncementOptions): Record<string, unknown> {
if (options.action === "list") return { action: "list", page: options.page, pageSize: options.pageSize, status: options.status, search: options.search, sortBy: options.sortBy, sortOrder: options.sortOrder };
if (options.action === "get") return { action: "get", id: options.id };
return {
action: "create",
confirm: options.confirm,
announcement: { title: options.title, content: options.content, status: options.announcementStatus, notify_mode: options.notifyMode, targeting: options.targeting, starts_at: options.startsAt, ends_at: options.endsAt },
};
}
function renderAnnouncements(response: Record<string, unknown>): RenderedCliResult {
const request = record(response.request);
const report = record(response.report);
const action = String(request.action ?? "-");
const lines = [`ANNOUNCEMENTS action=${action} mode=${text(report.mode)} ok=${text(response.ok)} mutation=${text(report.mutation)}`];
if (action === "list") {
const items = arrayOfRecords(report.items);
lines.push(`SUMMARY total=${text(report.total)} page=${text(report.page)} page-size=${text(report.pageSize)} returned=${items.length}`);
if (items.length > 0) lines.push("", renderTable([["ID", "STATUS", "NOTIFY", "TITLE", "STARTS_AT", "ENDS_AT"], ...items.map((item) => [text(item.id), text(item.status), text(item.notify_mode), text(item.title), text(item.starts_at), text(item.ends_at)])]));
} else {
const announcement = record(report.announcement);
const mismatchedFields = Array.isArray(report.mismatchedFields) ? report.mismatchedFields.join(",") : "-";
lines.push(`WRITE attempted=${text(report.writeAttempted)} reconciled=${text(report.reconciled)} id=${text(announcement.id)} mismatched-fields=${mismatchedFields || "-"}`);
lines.push(`TITLE ${text(announcement.title)}`, `STATUS ${text(announcement.status)} NOTIFY_MODE ${text(announcement.notify_mode)}`, `STARTS_AT ${text(announcement.starts_at)} ENDS_AT ${text(announcement.ends_at)}`, `TARGETING ${JSON.stringify(record(announcement.targeting))}`, "CONTENT", String(announcement.content ?? ""));
}
if (typeof report.error === "string") lines.push(`ERROR ${text(report.error)}`);
return { ok: response.ok === true, command: "platform-infra sub2api codex-pool announcements", renderedText: lines.join("\n"), contentType: "text/plain", projection: response };
}
function renderAnnouncementsJson(response: Record<string, unknown>): RenderedCliResult {
return { ok: response.ok === true, command: "platform-infra sub2api codex-pool announcements --json", renderedText: JSON.stringify(response), contentType: "application/json", projection: response };
}
function renderAnnouncementsHelp(): RenderedCliResult {
const renderedText = [
"platform-infra sub2api codex-pool announcements list [--page 1] [--page-size 20] [--status draft|active|archived] [--search text] [--sort-by created_at] [--sort-order asc|desc] [--target PK01] [--json]",
"platform-infra sub2api codex-pool announcements get --id <positive-integer> [--target PK01] [--json]",
"platform-infra sub2api codex-pool announcements create --title <text> (--content <markdown>|--content-file <path>) [--announcement-status draft|active|archived] [--notify-mode silent|popup] [--targeting-json <json-object>] [--starts-at <unix-seconds|RFC3339>] [--ends-at <unix-seconds|RFC3339>] [--target PK01] [--confirm] [--json]",
"create defaults to dry-run; only --confirm calls the native Sub2API create API and then reconciles every planned announcement field after reading the returned ID back.",
].join("\n");
return { ok: true, command: "platform-infra sub2api codex-pool announcements --help", renderedText, contentType: "text/plain", projection: { ok: true, mutation: false } };
}
function parseTargeting(value: string): Record<string, unknown> { const parsed: unknown = JSON.parse(value); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("--targeting-json requires a JSON object"); return parsed as Record<string, unknown>; }
function parseTimestamp(value: string, option: string): number { if (/^[1-9][0-9]*$/u.test(value)) return Number(value); const millis = Date.parse(value); if (!Number.isFinite(millis)) throw new Error(`${option} requires Unix seconds or RFC3339`); return Math.floor(millis / 1000); }
function positiveInteger(value: string, option: string): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${option} requires a positive integer`); return parsed; }
function boundedPageSize(value: string): number { const parsed = positiveInteger(value, "--page-size"); if (parsed > 100) throw new Error("--page-size must be at most 100"); return parsed; }
function parseSortOrder(value: string): "asc" | "desc" { if (value !== "asc" && value !== "desc") throw new Error("--sort-order must be asc or desc"); return value; }
function parseAnnouncementStatus(value: string): "draft" | "active" | "archived" { if (value !== "draft" && value !== "active" && value !== "archived") throw new Error("--announcement-status must be draft, active, or archived"); return value; }
function parseNotifyMode(value: string): "silent" | "popup" { if (value !== "silent" && value !== "popup") throw new Error("--notify-mode must be silent or popup"); return value; }
function targetSummary(target: ReturnType<typeof codexPoolRuntimeTarget>): Record<string, unknown> { return { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns }; }
function record(value: unknown): Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
function arrayOfRecords(value: unknown): Record<string, unknown>[] { return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)) : []; }
function text(value: unknown): string { return value === null || value === undefined || value === "" ? "-" : String(value).replace(/[\r\n\t]+/gu, " "); }
@@ -21,7 +21,7 @@ import {
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolProfitConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexRuntimeConfig, CodexRuntimeTemplate, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types";
import type { CodexPoolCaddyEdgeRetryConfig, CodexPoolConfig, CodexPoolCreditsConfig, CodexPoolLocalCodexConfig, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProtection, CodexPoolManualAccountProxyBinding, CodexPoolManualAccountsConfig, CodexPoolManualBindingKind, CodexPoolManualBindingProvider, CodexPoolManualBindingSource, CodexPoolManualBindingSourcesConfig, CodexPoolProfileConfig, CodexPoolProfitConfig, CodexPoolPublicExposureConfig, CodexProfile, CodexRuntimeConfig, CodexRuntimeTemplate, CodexSentinelProtectPolicy, CodexTempUnschedulablePolicy, CodexTempUnschedulableRule, OpenAIResponsesWebSocketsV2Mode } from "./types";
import { booleanValue, integerArrayConfigField, integerConfigField, isRecord, normalizeBaseUrl, numberValue, readRequiredBaseUrl, readRequiredDescription, readRequiredEmail, readRequiredPort, readRequiredPositiveNumber, requiredRecordConfigField, requiredStringConfigField, stringValue, validateKubernetesName } from "./config-utils";
import { readAuthAPIKey, uniqueAccountName } from "./redaction";
import { codexPoolConfigPath } from "./types";
@@ -180,6 +180,7 @@ export function readCodexPoolConfig(): CodexPoolConfig {
profiles,
runtime,
profit: readProfitConfig(parsed.profit),
credits: readCreditsConfig(parsed.credits),
manualAccounts,
publicExposure: readPublicExposureConfig(parsed.publicExposure),
localCodex: readLocalCodexConfig(parsed.localCodex),
@@ -200,6 +201,24 @@ export function readCodexPoolConfig(): CodexPoolConfig {
return config;
}
function readCreditsConfig(value: unknown): CodexPoolCreditsConfig {
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.credits must be a YAML object`);
const timezone = requiredStringConfigField(value, "timezone", "credits");
const excludedUsers = readUniqueStringArray(value.excludedUsers, "credits.excludedUsers", true);
if (excludedUsers.some((selector) => selector.length > 256 || /[\r\n\0]/u.test(selector))) {
throw new Error(`${codexPoolConfigPath}.credits.excludedUsers contains an unsupported selector`);
}
if (new Set(excludedUsers.map((selector) => selector.toLowerCase())).size !== excludedUsers.length) {
throw new Error(`${codexPoolConfigPath}.credits.excludedUsers must not contain case-insensitive duplicates`);
}
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date(0));
} catch {
throw new Error(`${codexPoolConfigPath}.credits.timezone must be a valid IANA timezone`);
}
return { timezone, excludedUsers };
}
function readProfitConfig(value: unknown): CodexPoolProfitConfig {
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.profit must be a YAML object`);
const groups = requiredRecordConfigField(value, "groups", "profit");
@@ -4,9 +4,16 @@ import { renderTable } from "./render";
import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
import { creditsScript } from "./remote-scripts";
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target";
import { readCodexPoolConfig } from "./config";
interface CreditsOptions {
activeSince: string | null;
activeStart: string | null;
activeEnd: string | null;
activeStartUtc: string | null;
activeEndUtc: string | null;
timezone: string;
excludedUsers: string[];
users: string[];
amountUsd: number;
reason: string;
@@ -17,9 +24,10 @@ interface CreditsOptions {
export async function codexPoolCredits(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
if (args.includes("--help")) return renderCreditsHelp();
const options = parseCreditsOptions(args);
const pool = readCodexPoolConfig();
const options = parseCreditsOptions(args, pool.credits);
const target = codexPoolRuntimeTarget(options.targetId);
const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, target), target);
const remote = await runRemoteCodexPoolScript(config, "credits", creditsScript(options, pool, target), target);
const report = parseJsonOutput(remote.stdout);
const ok = remote.exitCode === 0 && boolField(report, "ok", false);
const response = {
@@ -27,8 +35,14 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P
action: "platform-infra-sub2api-codex-pool-credits",
target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns },
request: {
selection: options.activeSince === null ? "exact-users" : "active-users",
selection: options.users.length > 0 ? "exact-users" : options.activeSince === null ? "active-window" : "active-users",
activeSince: options.activeSince,
activeStart: options.activeStart,
activeEnd: options.activeEnd,
activeStartUtc: options.activeStartUtc,
activeEndUtc: options.activeEndUtc,
timezone: options.timezone,
excludedUsers: options.excludedUsers,
users: options.users,
amountUsd: options.amountUsd,
reason: options.reason,
@@ -41,10 +55,13 @@ export async function codexPoolCredits(config: UniDeskConfig, args: string[]): P
return options.json ? renderCreditsJson(response) : renderCredits(response);
}
function parseCreditsOptions(args: string[]): CreditsOptions {
function parseCreditsOptions(args: string[], creditsConfig: { timezone: string; excludedUsers: string[] }): CreditsOptions {
const { timezone, excludedUsers } = creditsConfig;
const [action = "grant", ...rest] = args;
if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--users <id-or-email,...>) --amount-usd <amount> --reason <text> [--confirm] [--target <id>] [--json]");
if (action !== "grant") throw new Error("credits usage: grant (--active-since 24h|--active-start <ISO> --active-end <ISO>|--users <id-or-email,...>) --amount-usd <amount> --reason <text> [--confirm] [--target <id>] [--json]");
let activeSince: string | null = null;
let activeStart: string | null = null;
let activeEnd: string | null = null;
let users: string[] = [];
let amountUsd: number | null = null;
let reason: string | null = null;
@@ -62,6 +79,10 @@ function parseCreditsOptions(args: string[]): CreditsOptions {
else if (arg === "--json") json = true;
else if (arg === "--active-since") [activeSince, index] = readValue(index, arg);
else if (arg.startsWith("--active-since=")) activeSince = arg.slice("--active-since=".length).trim();
else if (arg === "--active-start") [activeStart, index] = readValue(index, arg);
else if (arg.startsWith("--active-start=")) activeStart = arg.slice("--active-start=".length).trim();
else if (arg === "--active-end") [activeEnd, index] = readValue(index, arg);
else if (arg.startsWith("--active-end=")) activeEnd = arg.slice("--active-end=".length).trim();
else if (arg === "--users") {
let value: string;
[value, index] = readValue(index, arg);
@@ -78,12 +99,64 @@ function parseCreditsOptions(args: string[]): CreditsOptions {
else if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length).trim();
else throw new Error(`unsupported credits option: ${arg}`);
}
if ((activeSince === null) === (users.length === 0)) throw new Error("use exactly one of --active-since or --users");
if ((activeStart === null) !== (activeEnd === null)) throw new Error("--active-start and --active-end must be provided together");
const selectionCount = Number(activeSince !== null) + Number(activeStart !== null) + Number(users.length > 0);
if (selectionCount !== 1) throw new Error("use exactly one of --active-since, --active-start/--active-end, or --users");
if (activeSince !== null && !/^[1-9][0-9]*(?:m|h|d)$/u.test(activeSince)) throw new Error("--active-since must use a duration such as 24h");
if (confirm && activeSince !== null) throw new Error("confirmed credits require the exact --users selectors returned by an active-user dry-run");
if (activeStart !== null && !isIsoDateTime(activeStart)) throw new Error("--active-start must use ISO date-time format");
if (activeEnd !== null && !isIsoDateTime(activeEnd)) throw new Error("--active-end must use ISO date-time format");
const activeStartUtc = activeStart === null ? null : isoInTimezoneToUtc(activeStart, timezone);
const activeEndUtc = activeEnd === null ? null : isoInTimezoneToUtc(activeEnd, timezone);
if (activeStartUtc !== null && activeEndUtc !== null && activeEndUtc <= activeStartUtc) {
throw new Error("credits active window end must be after start");
}
if (confirm && users.length === 0) throw new Error("confirmed credits require the exact --users selectors returned by an active-user dry-run");
if (amountUsd === null) throw new Error("credits grant requires --amount-usd");
if (reason === null || reason.length === 0 || reason.length > 500 || /[\r\n\0]/u.test(reason)) throw new Error("credits grant requires a single-line --reason up to 500 characters");
return { activeSince, users, amountUsd, reason, confirm, targetId, json };
return { activeSince, activeStart, activeEnd, activeStartUtc, activeEndUtc, timezone, excludedUsers, users, amountUsd, reason, confirm, targetId, json };
}
function isIsoDateTime(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?(?:Z|[+-]\d{2}:\d{2})?$/u.test(value);
}
function isoInTimezoneToUtc(value: string, timezone: string): string {
if (/(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) {
const instant = new Date(value);
if (!Number.isFinite(instant.getTime())) throw new Error("fixed-window date-time is invalid");
return instant.toISOString();
}
const matched = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?$/u.exec(value);
if (matched === null) throw new Error("fixed-window date-time is invalid");
const components = matched.slice(1, 7).map((item) => Number(item ?? 0));
const milliseconds = Number((matched[7] ?? "").padEnd(3, "0").slice(0, 3));
const localEpoch = Date.UTC(components[0]!, components[1]! - 1, components[2]!, components[3]!, components[4]!, components[5]!, milliseconds);
let instant = localEpoch;
for (let attempt = 0; attempt < 3; attempt += 1) {
const actual = timezoneComponents(instant, timezone);
const represented = Date.UTC(actual[0], actual[1] - 1, actual[2], actual[3], actual[4], actual[5], milliseconds);
instant = localEpoch - (represented - instant);
}
const actual = timezoneComponents(instant, timezone);
if (actual.some((item, index) => item !== components[index])) {
throw new Error(`fixed-window date-time does not exist in timezone ${timezone}: ${value}`);
}
return new Date(instant).toISOString();
}
function timezoneComponents(epochMs: number, timezone: string): number[] {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(epochMs));
const values = new Map(parts.map((part) => [part.type, Number(part.value)]));
return ["year", "month", "day", "hour", "minute", "second"].map((key) => values.get(key) ?? Number.NaN);
}
function parseSelectors(value: string): string[] {
@@ -108,9 +181,12 @@ function renderCreditsHelp(): RenderedCliResult {
"SUB2API USER CREDITS",
"Usage:",
" bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-since 24h --amount-usd 20 --reason <text> [--confirm]",
" bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --active-start <ISO> --active-end <ISO> --amount-usd 20 --reason <text>",
" bun scripts/cli.ts platform-infra sub2api codex-pool credits grant --users <id-or-email,...> --amount-usd 20 --reason <text> [--confirm]",
"Notes:",
" Default mode is dry-run. --active-since selects distinct active non-admin users from native usage.",
" Offset-free fixed-window values use config/platform-infra/sub2api-codex-pool.yaml#credits.timezone and the interval is [start,end).",
" YAML credits.excludedUsers selectors are excluded from both active-window and exact-user grants.",
" --confirm performs additive balance writes and then reads every selected user back.",
].join("\n"),
contentType: "text/plain",
@@ -121,10 +197,15 @@ function renderCredits(response: Record<string, unknown>): RenderedCliResult {
const report = record(response.report);
const summary = record(report.summary);
const rows = arrayOfRecords(report.items);
const excludedRows = arrayOfRecords(report.excluded);
const lines = [
`SUB2API USER CREDITS ok=${response.ok === true} mode=${text(report.mode)} selection=${text(record(report.selection).kind)} selected=${text(summary.selected)} amount-usd=${text(summary.amountUsd)} total-usd=${text(summary.totalAmountUsd)}`,
`SUB2API USER CREDITS ok=${response.ok === true} mode=${text(report.mode)} selection=${text(record(report.selection).kind)} selected=${text(summary.selected)} excluded=${text(summary.excluded)} amount-usd=${text(summary.amountUsd)} total-usd=${text(summary.totalAmountUsd)}`,
`WRITE attempted=${text(summary.writeAttempted)} succeeded=${text(summary.writeSucceeded)} failed=${text(summary.writeFailed)} reconciled=${text(summary.reconciled)} mutation=${text(report.mutation)}`,
];
const selection = record(report.selection);
if (selection.kind === "active-users" || selection.kind === "active-window") {
lines.push(`WINDOW timezone=${text(selection.timezone)} start=${text(selection.startAt)} end=${text(selection.endAt)} start-utc=${text(selection.startAtUtc)} end-utc=${text(selection.endAtUtc)}`);
}
if (typeof report.error === "string") lines.push(`ERROR ${text(report.error)}`);
if (rows.length > 0) {
lines.push("", renderTable([
@@ -135,10 +216,21 @@ function renderCredits(response: Record<string, unknown>): RenderedCliResult {
]),
]));
}
if (excludedRows.length > 0) {
lines.push("", "EXCLUDED", renderTable([
["EMAIL", "USER_ID", "REQUESTS", "REASON", "MATCHED_SELECTOR"],
...excludedRows.map((row) => [
text(row.email), text(row.userId), text(row.requestCount), text(row.reason), text(row.matchedSelector),
]),
]));
}
if (report.mode === "dry-run") {
const selectors = Array.isArray(report.confirmationUserIds) ? report.confirmationUserIds.join(",") : "<exact-user-ids>";
const confirmationUserIds = Array.isArray(report.confirmationUserIds) ? report.confirmationUserIds : [];
const selectors = confirmationUserIds.length > 0 ? confirmationUserIds.join(",") : "<none>";
lines.push("", `CONFIRM SELECTORS --users ${selectors}`);
lines.push("NEXT: use these exact selectors with --confirm only after explicit user authorization.");
lines.push(confirmationUserIds.length > 0
? "NEXT: use these exact selectors with --confirm only after explicit user authorization."
: "NEXT: no users remain eligible for confirmation.");
}
return { ok: response.ok === true, command: "platform-infra sub2api codex-pool credits", renderedText: lines.join("\n"), contentType: "text/plain", projection: response };
}
@@ -16,4 +16,5 @@ export * from "./remote";
export * from "./feedback";
export * from "./profit";
export * from "./credits";
export * from "./announcements";
export * from "./error-passthrough";
@@ -29,6 +29,7 @@ import { codexPoolRuntime } from "./runtime";
import { codexPoolFaults } from "./faults";
import { codexPoolProfit } from "./profit";
import { codexPoolCredits } from "./credits";
import { codexPoolAnnouncements } from "./announcements";
import { codexPoolErrorPassthrough } from "./error-passthrough";
import { defaultCodexPoolRuntimeTargetId } from "./runtime-target";
import { codexPoolHelp } from "./types";
@@ -46,6 +47,7 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[])
if (action === "faults") return await codexPoolFaults(config, args.slice(1));
if (action === "profit") return await codexPoolProfit(config, args.slice(1));
if (action === "credits") return await codexPoolCredits(config, args.slice(1));
if (action === "announcements") return await codexPoolAnnouncements(config, args.slice(1));
if (action === "error-passthrough") return await codexPoolErrorPassthrough(config, args.slice(1));
if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1)));
if (action === "feedback") return await codexPoolFeedback(config, parseFeedbackOptions(args.slice(1)));
@@ -0,0 +1,108 @@
export const remotePythonAnnouncementsScript = String.raw`
def announcements_payload():
return json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8"))
def announcements_list(token, payload):
query = [
"page=" + quote(str(payload.get("page") or 1), safe=""),
"page_size=" + quote(str(payload.get("pageSize") or 20), safe=""),
"sort_by=" + quote(str(payload.get("sortBy") or "created_at"), safe=""),
"sort_order=" + quote(str(payload.get("sortOrder") or "desc"), safe=""),
]
if payload.get("status"):
query.append("status=" + quote(str(payload.get("status")), safe=""))
if payload.get("search"):
query.append("search=" + quote(str(payload.get("search")), safe=""))
data = ensure_success(curl_api("GET", "/api/v1/admin/announcements?" + "&".join(query), bearer=token), "list announcements")
items = extract_items(data)
return {
"ok": True, "mode": "read-only", "mutation": False, "writeAttempted": 0,
"items": items,
"total": data.get("total", len(items)) if isinstance(data, dict) else len(items),
"page": data.get("page", payload.get("page")) if isinstance(data, dict) else payload.get("page"),
"pageSize": data.get("page_size", payload.get("pageSize")) if isinstance(data, dict) else payload.get("pageSize"),
"totalPages": data.get("total_pages") if isinstance(data, dict) else None,
"valuesPrinted": False,
}
def announcements_get(token, announcement_id):
item = ensure_success(curl_api("GET", f"/api/v1/admin/announcements/{announcement_id}", bearer=token), "get announcement")
return {"ok": isinstance(item, dict), "mode": "read-only", "mutation": False, "writeAttempted": 0, "announcement": item, "valuesPrinted": False}
def announcement_time_seconds(value):
if value is None or value == "":
return None
if isinstance(value, (int, float)) and not isinstance(value, bool):
return int(value)
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return int(parsed.timestamp())
except ValueError:
return value
return value
def announcement_targeting(value):
if not isinstance(value, dict):
return value
normalized = dict(value)
if normalized.get("any_of") is None:
normalized["any_of"] = []
return normalized
def announcements_reconcile(expected, actual):
fields = ("title", "content", "status", "notify_mode", "targeting", "starts_at", "ends_at")
expected_values = {}
actual_values = {}
mismatched_fields = []
for field in fields:
expected_value = expected.get(field) if isinstance(expected, dict) else None
actual_value = actual.get(field) if isinstance(actual, dict) else None
if field in ("starts_at", "ends_at"):
expected_value = announcement_time_seconds(expected_value)
actual_value = announcement_time_seconds(actual_value)
elif field == "targeting":
expected_value = announcement_targeting(expected_value)
actual_value = announcement_targeting(actual_value)
expected_values[field] = expected_value
actual_values[field] = actual_value
if expected_value != actual_value:
mismatched_fields.append(field)
return {"expected": expected_values, "actual": actual_values, "mismatchedFields": mismatched_fields}
def announcements_create(token, payload):
announcement = payload.get("announcement") if isinstance(payload.get("announcement"), dict) else {}
created = ensure_success(curl_api("POST", "/api/v1/admin/announcements", bearer=token, payload=announcement), "create announcement")
announcement_id = created.get("id") if isinstance(created, dict) else None
if not isinstance(announcement_id, int) or announcement_id <= 0:
raise RuntimeError("create announcement response missing positive id")
actual = ensure_success(curl_api("GET", f"/api/v1/admin/announcements/{announcement_id}", bearer=token), "read announcement after create")
reconciliation = announcements_reconcile(announcement, actual)
id_matched = isinstance(actual, dict) and actual.get("id") == announcement_id
mismatched_fields = list(reconciliation.get("mismatchedFields") or [])
if not id_matched:
mismatched_fields.insert(0, "id")
reconciled = id_matched and len(mismatched_fields) == 0
return {
"ok": reconciled, "mode": "confirmed", "mutation": True, "writeAttempted": 1,
"writeSucceeded": 1, "writeFailed": 0, "reconciled": reconciled,
"mismatchedFields": mismatched_fields,
"reconciliation": {**reconciliation, "idMatched": id_matched},
"announcement": actual, "valuesPrinted": False,
}
def run_announcements():
payload = announcements_payload()
_, token, _ = login()
action = payload.get("action")
if action == "list":
return announcements_list(token, payload)
if action == "get":
return announcements_get(token, payload.get("id"))
if action == "create" and payload.get("confirm") is True:
return announcements_create(token, payload)
raise RuntimeError("unsupported announcements action")
`;
@@ -30,7 +30,7 @@ function pyJson(value: unknown): string {
return `json.loads(${JSON.stringify(JSON.stringify(value))})`;
}
export function remotePythonCoreScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
export function remotePythonCoreScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits" | "announcements", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
const hostDockerEnvPath = target.runtimeMode === "host-docker" ? target.hostDockerEnvPath : null;
return `
set -u
@@ -28,9 +28,17 @@ def credits_all_users(token):
return users
page += 1
def credits_active_usage(token, since):
end_at = datetime.now(timezone.utc)
start_at = credits_window_start(since, end_at)
def credits_active_usage(token, since, active_start, active_end, active_start_utc, active_end_utc, timezone_name):
if since:
end_at = datetime.now(timezone.utc)
start_at = credits_window_start(since, end_at)
selection_kind = "active-users"
else:
start_at = credits_parse_time(active_start_utc)
end_at = credits_parse_time(active_end_utc)
selection_kind = "active-window"
if start_at is None or end_at is None or end_at <= start_at:
raise RuntimeError("credits active window end must be after start")
user_requests = {}
page = 1
while True:
@@ -46,34 +54,63 @@ def credits_active_usage(token, since):
reached_start = True
continue
user_id = row.get("user_id") if row.get("user_id") is not None else row.get("userId")
if user_id is not None and (created_at is None or created_at <= end_at):
if user_id is not None and created_at is not None and start_at <= created_at < end_at:
user_requests[str(user_id)] = user_requests.get(str(user_id), 0) + 1
total = int(data.get("total", 0)) if isinstance(data, dict) else 0
if not batch or len(batch) < 100 or reached_start or page * 100 >= total:
return user_requests, start_at, end_at
return user_requests, start_at, end_at, {
"kind": selection_kind,
"since": since,
"timezone": timezone_name if selection_kind == "active-window" else "UTC",
"startAt": active_start if selection_kind == "active-window" else start_at.isoformat(),
"endAt": active_end if selection_kind == "active-window" else end_at.isoformat(),
"startAtUtc": start_at.isoformat().replace("+00:00", "Z"),
"endAtUtc": end_at.isoformat().replace("+00:00", "Z"),
}
page += 1
def credits_excluded_selector(user, payload):
user_id = str(user.get("id")) if user.get("id") is not None else ""
email = str(user.get("email") or "").strip().lower()
for selector in payload.get("excludedUsers") or []:
normalized = str(selector).strip().lower()
if normalized == user_id or normalized == email:
return str(selector)
return None
def credits_selected_users(token, payload):
users = credits_all_users(token)
users_by_id = {str(item.get("id")): item for item in users if item.get("id") is not None}
users_by_email = {str(item.get("email") or "").strip().lower(): item for item in users if item.get("email")}
active_since = payload.get("activeSince")
if active_since:
requests, start_at, end_at = credits_active_usage(token, active_since)
active_start = payload.get("activeStart")
active_end = payload.get("activeEnd")
if active_since or (active_start and active_end):
requests, _, _, selection = credits_active_usage(
token, active_since, active_start, active_end,
payload.get("activeStartUtc"), payload.get("activeEndUtc"), payload.get("timezone"),
)
selected = []
excluded = []
for user_id, request_count in requests.items():
user = users_by_id.get(user_id)
excluded_selector = credits_excluded_selector(user, payload) if isinstance(user, dict) else None
if not isinstance(user, dict):
excluded.append({"userId": user_id, "reason": "user-not-found"})
elif excluded_selector is not None:
excluded.append({
"userId": user.get("id"), "email": user.get("email"), "requestCount": request_count,
"reason": "yaml-excluded-user", "matchedSelector": excluded_selector,
})
elif user.get("role") == "admin":
excluded.append({"userId": user.get("id"), "email": user.get("email"), "reason": "admin-excluded"})
elif user.get("status") != "active" or user.get("deleted_at") is not None:
excluded.append({"userId": user.get("id"), "email": user.get("email"), "reason": "inactive-or-deleted"})
else:
selected.append((user, request_count))
return selected, excluded, {"kind": "active-users", "since": active_since, "startAt": start_at.isoformat(), "endAt": end_at.isoformat()}
return selected, excluded, selection
selected = []
excluded = []
seen = set()
for selector in payload.get("users") or []:
user = users_by_id.get(str(selector)) or users_by_email.get(str(selector).strip().lower())
@@ -83,8 +120,15 @@ def credits_selected_users(token, payload):
if user_id in seen:
raise RuntimeError("duplicate user selector: " + str(selector))
seen.add(user_id)
selected.append((user, None))
return selected, [], {"kind": "exact-users", "selectors": payload.get("users") or []}
excluded_selector = credits_excluded_selector(user, payload)
if excluded_selector is not None:
excluded.append({
"userId": user.get("id"), "email": user.get("email"), "requestCount": None,
"reason": "yaml-excluded-user", "matchedSelector": excluded_selector,
})
else:
selected.append((user, None))
return selected, excluded, {"kind": "exact-users", "selectors": payload.get("users") or []}
def run_credits_grant():
payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8")) if PAYLOAD_B64 else {}
@@ -174,6 +174,8 @@ try:
result = run_profit_report()
elif MODE == "credits":
result = run_credits_grant()
elif MODE == "announcements":
result = run_announcements()
else:
result = run_validate()
except Exception as exc:
@@ -9,8 +9,9 @@ import { remotePythonTraceScript } from "./remote-python-trace";
import { remotePythonSentinelProbeScript } from "./remote-python-sentinel-probe";
import { remotePythonProfitScript } from "./remote-python-profit";
import { remotePythonCreditsScript } from "./remote-python-credits";
import { remotePythonAnnouncementsScript } from "./remote-python-announcements";
export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanup-probes" | "sentinel-probe" | "profit" | "credits" | "announcements", encodedPayload: string, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
return [
remotePythonCoreScript(mode, encodedPayload, pool, target),
remotePythonSentinelScript,
@@ -19,6 +20,7 @@ export function remotePythonScript(mode: "sync" | "validate" | "trace" | "cleanu
remotePythonTraceScript,
remotePythonProfitScript,
remotePythonCreditsScript,
remotePythonAnnouncementsScript,
remotePythonSentinelProbeScript,
].join("");
}
@@ -57,12 +57,17 @@ export function profitScript(payload: unknown, pool: CodexPoolConfig, target: Co
return remotePythonScript("profit", encoded, pool, target);
}
export function creditsScript(payload: unknown, target: CodexPoolRuntimeTarget): string {
const pool = readCodexPoolConfig();
export function creditsScript(payload: unknown, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
return remotePythonScript("credits", encoded, pool, target);
}
export function announcementsScript(payload: unknown, target: CodexPoolRuntimeTarget): string {
const pool = readCodexPoolConfig();
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
return remotePythonScript("announcements", encoded, pool, target);
}
export function sentinelReportScript(pool: CodexPoolConfig, events: number, target: CodexPoolRuntimeTarget): string {
const stateName = pool.sentinel.stateConfigMapName;
const cronJobName = pool.sentinel.cronJobName;
@@ -219,6 +219,11 @@ export interface CodexPoolProfitConfig {
};
}
export interface CodexPoolCreditsConfig {
timezone: string;
excludedUsers: string[];
}
export interface CodexPoolConfig {
version: number;
kind: string;
@@ -245,6 +250,7 @@ export interface CodexPoolConfig {
profiles: CodexPoolProfileConfig[];
runtime: CodexRuntimeConfig;
profit: CodexPoolProfitConfig;
credits: CodexPoolCreditsConfig;
manualAccounts: CodexPoolManualAccountsConfig;
publicExposure: CodexPoolPublicExposureConfig;
localCodex: CodexPoolLocalCodexConfig;
@@ -389,7 +395,7 @@ export function codexPoolHelp(): unknown {
const pool = readCodexPoolConfig();
const runtimeTarget = codexPoolRuntimeTarget();
return {
command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|profit|credits|error-passthrough|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local",
command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|profit|credits|announcements|error-passthrough|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local",
output: "json, except trace, feedback, and sentinel-report default to low-noise text tables",
usage: [
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
@@ -401,7 +407,8 @@ export function codexPoolHelp(): unknown {
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--group <name-or-id>|--all-groups] [--platform <name>] [--page-token <group-id>] [--account <name-or-id>] [--since 24h] [--tail 50000] [--target PK01] [--json|--full|--raw]",
"bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group <name-or-id>] [--account <name-or-id>] [--model <model>] [--stream sync|stream] [--endpoint <path>] [--request-id <id>] [--page-token <token>] [--target PK01] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool profit [--since 24h|--month YYYY-MM [--forecast]] [--target PK01] [--sale-rate <CNY/API_USD>] [--scenario-without-account <id-or-exact-name> ...] [--accounts [--page-token <token>]|--missing-costs] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--users <id-or-email,...>) --amount-usd <amount> --reason <text> [--target PK01] [--confirm] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool credits grant (--active-since 24h|--active-start <ISO> --active-end <ISO>|--users <id-or-email,...>) --amount-usd <amount> --reason <text> [--target PK01] [--confirm] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool announcements list|get|create [--target PK01] [--confirm for create] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list|apply|delete [--template <id>] [--target PK01] [--confirm] [--json]",
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account <name-or-id> --template <id> [--target PK01] [--confirm]",
"bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts <name-or-id,...> --template <id> [--target PK01] [--confirm] [--full|--raw]",