feat: 增加YAML受控swap扩容
This commit is contained in:
+1
-1
@@ -78,7 +78,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot, n8n and Web Terminal, manage YAML-controlled public Caddy/FRP exposure and WeChat archive workflows, and inspect status/logs without printing secrets." },
|
||||
{ command: "pikaoa attachments-backup ...", description: "管理 PikaOA 生产附件离机备份控制面;开发与生产运行面由 PaC 自动交付。" },
|
||||
{ command: "secrets plan|sync|status", description: "Plan, push and inspect YAML-declared local secret source keys to Kubernetes Secrets without printing or reverse-engineering values." },
|
||||
{ command: "platform-db postgres plan|status|export-secrets|apply", description: "Manage YAML-declared host-native PostgreSQL 16 on PK01 for Sub2API/platform state, with PostgreSQL native TLS and redacted secret exports." },
|
||||
{ command: "platform-db postgres plan|status|export-secrets|apply|swap", description: "Manage YAML-declared host-native PostgreSQL and its node swap capacity through bounded plans, asynchronous mutation, status, TLS and redacted secret output." },
|
||||
{ command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Archived HWLAB DEV CD diagnostics only; current HWLAB rollout uses NC01 GitOps." },
|
||||
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
|
||||
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { parseJsonOutput } from "./platform-infra-ops-library";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
|
||||
export interface HostSwapDeclaration {
|
||||
configPath: string;
|
||||
nodeId: string;
|
||||
route: string;
|
||||
minDiskFreeGiB: number;
|
||||
swap: {
|
||||
ensure: boolean;
|
||||
size: string;
|
||||
path: string;
|
||||
expansionPath: string;
|
||||
swappiness: number;
|
||||
sysctlFile: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface HostSwapOptions {
|
||||
configPath: string;
|
||||
confirm: boolean;
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
jobId: string | null;
|
||||
}
|
||||
|
||||
type HostSwapDeclarationLoader = (configPath: string) => HostSwapDeclaration;
|
||||
|
||||
const defaultConfigPath = "config/platform-db/postgres-pk01.yaml";
|
||||
|
||||
export async function runPlatformDbHostSwapCommand(
|
||||
config: UniDeskConfig,
|
||||
action: string,
|
||||
args: string[],
|
||||
loadDeclaration: HostSwapDeclarationLoader,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const options = parseOptions(args);
|
||||
const declaration = loadDeclaration(options.configPath);
|
||||
if (!declaration.swap.ensure) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "host-swap-management-disabled-by-yaml",
|
||||
mutation: false,
|
||||
configSource: `${declaration.configPath}#node.swap.ensure`,
|
||||
};
|
||||
}
|
||||
if (action === "plan") return await plan(config, declaration, options);
|
||||
if (action === "status") return await status(config, declaration, options);
|
||||
if (action === "apply") return await apply(config, declaration, options);
|
||||
return { ok: false, error: "unsupported-platform-db-swap-command", action, help: swapHelp(declaration.configPath) };
|
||||
}
|
||||
|
||||
export function swapHelp(configPath = defaultConfigPath): Record<string, unknown> {
|
||||
return {
|
||||
command: "platform-db postgres swap plan|status|apply",
|
||||
output: "json",
|
||||
usage: [
|
||||
`bun scripts/cli.ts platform-db postgres swap plan --config ${configPath}`,
|
||||
`bun scripts/cli.ts platform-db postgres swap status --config ${configPath}`,
|
||||
`bun scripts/cli.ts platform-db postgres swap apply --config ${configPath} --confirm`,
|
||||
`bun scripts/cli.ts platform-db postgres swap status --config ${configPath} --job-id <id>`,
|
||||
],
|
||||
safety: {
|
||||
sourceOfTruth: `${configPath}#node.swap`,
|
||||
asynchronousMutation: true,
|
||||
expansion: "keep every active swap area online and add only the YAML-declared missing capacity",
|
||||
webProbe: "swap remains excluded from the WebProbe MemAvailable admission threshold",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): HostSwapOptions {
|
||||
const options: HostSwapOptions = { configPath: defaultConfigPath, confirm: false, full: false, raw: false, jobId: null };
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--config") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.trim().length === 0) throw new Error("--config requires a non-empty path");
|
||||
options.configPath = value;
|
||||
index += 1;
|
||||
} else if (arg === "--job-id") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || !/^[A-Za-z0-9_.-]+$/u.test(value)) throw new Error("--job-id requires a safe non-empty identifier");
|
||||
options.jobId = value;
|
||||
index += 1;
|
||||
} else if (arg === "--confirm") {
|
||||
options.confirm = true;
|
||||
} else if (arg === "--full") {
|
||||
options.full = true;
|
||||
} else if (arg === "--raw") {
|
||||
options.raw = true;
|
||||
options.full = true;
|
||||
} else {
|
||||
throw new Error(`unsupported platform-db postgres swap option: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function plan(config: UniDeskConfig, declaration: HostSwapDeclaration, options: HostSwapOptions): Promise<Record<string, unknown>> {
|
||||
if (options.jobId !== null || options.confirm) throw new Error("swap plan does not accept --job-id or --confirm");
|
||||
const observed = await collect(config, declaration);
|
||||
const desiredBytes = parseByteSize(declaration.swap.size);
|
||||
const facts = observed.parsed;
|
||||
const declaredCapacity = numberValue(facts?.declaredCapacityBytes);
|
||||
const diskAvailable = numberValue(facts?.diskAvailableBytes);
|
||||
const reserveBytes = declaration.minDiskFreeGiB * 1024 ** 3;
|
||||
const additionalDiskBytes = declaredCapacity === null ? desiredBytes : Math.max(0, desiredBytes - declaredCapacity);
|
||||
const diskReady = diskAvailable !== null && diskAvailable >= additionalDiskBytes + reserveBytes;
|
||||
const inSync = isInSync(facts, declaration, desiredBytes);
|
||||
return {
|
||||
ok: observed.capture.exitCode === 0 && facts !== null && (inSync || diskReady),
|
||||
action: "platform-db-postgres-swap-plan",
|
||||
mutation: false,
|
||||
target: targetSummary(declaration),
|
||||
desired: desiredSummary(declaration, desiredBytes),
|
||||
observed: compactFacts(facts),
|
||||
plan: {
|
||||
action: inSync ? "none" : "add-declared-swap-capacity",
|
||||
inSync,
|
||||
diskReady,
|
||||
additionalDiskBytes,
|
||||
reserveBytes,
|
||||
swapExcludedFromWebProbeAvailability: true,
|
||||
steps: inSync ? [] : [
|
||||
"keep-all-current-swap-active",
|
||||
"allocate-and-enable-declared-extension",
|
||||
"persist-fstab-and-swappiness",
|
||||
"verify-declared-capacity",
|
||||
],
|
||||
},
|
||||
remote: compactCapture(observed.capture, options.full),
|
||||
...(options.raw ? { raw: observed.capture } : {}),
|
||||
next: inSync
|
||||
? { status: statusCommand(declaration) }
|
||||
: {
|
||||
apply: `bun scripts/cli.ts platform-db postgres swap apply --config ${declaration.configPath} --confirm`,
|
||||
status: statusCommand(declaration),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function status(config: UniDeskConfig, declaration: HostSwapDeclaration, options: HostSwapOptions): Promise<Record<string, unknown>> {
|
||||
if (options.confirm) throw new Error("swap status does not accept --confirm");
|
||||
if (options.jobId !== null) return await jobStatus(config, declaration, options.jobId, options.full, options.raw);
|
||||
const observed = await collect(config, declaration);
|
||||
const desiredBytes = parseByteSize(declaration.swap.size);
|
||||
const inSync = isInSync(observed.parsed, declaration, desiredBytes);
|
||||
return {
|
||||
ok: observed.capture.exitCode === 0 && observed.parsed !== null,
|
||||
action: "platform-db-postgres-swap-status",
|
||||
mutation: false,
|
||||
target: targetSummary(declaration),
|
||||
desired: desiredSummary(declaration, desiredBytes),
|
||||
observed: compactFacts(observed.parsed),
|
||||
policySync: { inSync, swapExcludedFromWebProbeAvailability: true },
|
||||
remote: compactCapture(observed.capture, options.full),
|
||||
...(options.raw ? { raw: observed.capture } : {}),
|
||||
next: inSync ? {} : { plan: `bun scripts/cli.ts platform-db postgres swap plan --config ${declaration.configPath}` },
|
||||
};
|
||||
}
|
||||
|
||||
async function apply(config: UniDeskConfig, declaration: HostSwapDeclaration, options: HostSwapOptions): Promise<Record<string, unknown>> {
|
||||
if (options.jobId !== null) throw new Error("swap apply does not accept --job-id");
|
||||
if (!options.confirm) return await plan(config, declaration, { ...options, confirm: false });
|
||||
const desiredBytes = parseByteSize(declaration.swap.size);
|
||||
const payload = {
|
||||
path: declaration.swap.path,
|
||||
expansionPath: declaration.swap.expansionPath,
|
||||
desiredBytes,
|
||||
minDiskFreeBytes: declaration.minDiskFreeGiB * 1024 ** 3,
|
||||
swappiness: declaration.swap.swappiness,
|
||||
sysctlFile: declaration.swap.sysctlFile,
|
||||
};
|
||||
const capture = await runSshCommandCapture(config, declaration.route, ["sh"], startJobScript(payload));
|
||||
const parsed = parseJsonOutput(capture.stdout);
|
||||
const jobId = stringValue(parsed?.jobId);
|
||||
const ok = capture.exitCode === 0 && parsed?.ok === true && jobId !== null;
|
||||
return {
|
||||
ok,
|
||||
action: "platform-db-postgres-swap-apply",
|
||||
mutation: ok,
|
||||
target: targetSummary(declaration),
|
||||
desired: desiredSummary(declaration, desiredBytes),
|
||||
remoteJob: parsed,
|
||||
remote: compactCapture(capture, options.full || !ok),
|
||||
...(options.raw ? { raw: capture } : {}),
|
||||
next: jobId === null
|
||||
? { plan: `bun scripts/cli.ts platform-db postgres swap plan --config ${declaration.configPath}` }
|
||||
: { status: `${statusCommand(declaration)} --job-id ${jobId}` },
|
||||
};
|
||||
}
|
||||
|
||||
async function collect(config: UniDeskConfig, declaration: HostSwapDeclaration): Promise<{ capture: SshCaptureResult; parsed: Record<string, unknown> | null }> {
|
||||
const encoded = Buffer.from(JSON.stringify({
|
||||
path: declaration.swap.path,
|
||||
expansionPath: declaration.swap.expansionPath,
|
||||
sysctlFile: declaration.swap.sysctlFile,
|
||||
}), "utf8").toString("base64");
|
||||
const capture = await runSshCommandCapture(config, declaration.route, ["sh"], factsScript(encoded));
|
||||
return { capture, parsed: parseJsonOutput(capture.stdout) };
|
||||
}
|
||||
|
||||
async function jobStatus(
|
||||
config: UniDeskConfig,
|
||||
declaration: HostSwapDeclaration,
|
||||
jobId: string,
|
||||
full: boolean,
|
||||
raw: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const capture = await runSshCommandCapture(config, declaration.route, ["sh"], `
|
||||
set -eu
|
||||
sudo -n sh <<'ROOT'
|
||||
job_dir="/root/.unidesk/platform-db/swap-jobs/${jobId}"
|
||||
if [ ! -d "$job_dir" ]; then
|
||||
printf '{"ok":false,"status":"missing","jobId":"${jobId}"}\\n'
|
||||
exit 0
|
||||
fi
|
||||
state='{}'
|
||||
result='null'
|
||||
[ ! -f "$job_dir/state.json" ] || state="$(cat "$job_dir/state.json")"
|
||||
[ ! -f "$job_dir/result.json" ] || result="$(cat "$job_dir/result.json")"
|
||||
python3 - "$state" "$result" <<'PY'
|
||||
import json, sys
|
||||
state=json.loads(sys.argv[1])
|
||||
result=json.loads(sys.argv[2])
|
||||
print(json.dumps({"ok": True, "job": state, "result": result}, separators=(",", ":")))
|
||||
PY
|
||||
ROOT
|
||||
`);
|
||||
const parsed = parseJsonOutput(capture.stdout);
|
||||
const statusValue = stringValue(recordValue(parsed?.job)?.status);
|
||||
return {
|
||||
ok: capture.exitCode === 0 && parsed?.ok === true,
|
||||
action: "platform-db-postgres-swap-job-status",
|
||||
mutation: false,
|
||||
target: targetSummary(declaration),
|
||||
jobId,
|
||||
job: parsed?.job ?? null,
|
||||
result: parsed?.result ?? null,
|
||||
terminal: statusValue === "succeeded" || statusValue === "failed",
|
||||
remote: compactCapture(capture, full || capture.exitCode !== 0),
|
||||
...(raw ? { raw: capture } : {}),
|
||||
next: statusValue === "succeeded"
|
||||
? { status: statusCommand(declaration) }
|
||||
: statusValue === "failed"
|
||||
? { plan: `bun scripts/cli.ts platform-db postgres swap plan --config ${declaration.configPath}` }
|
||||
: { status: `${statusCommand(declaration)} --job-id ${jobId}` },
|
||||
};
|
||||
}
|
||||
|
||||
function startJobScript(payload: Record<string, unknown>): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return `
|
||||
set -eu
|
||||
if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then
|
||||
printf '{"ok":false,"error":"sudo-noninteractive-unavailable"}\\n'
|
||||
exit 1
|
||||
fi
|
||||
sudo -n sh <<'ROOT'
|
||||
set -eu
|
||||
umask 077
|
||||
base="/root/.unidesk/platform-db/swap-jobs"
|
||||
mkdir -p "$base"
|
||||
job_id="swap-$(date +%Y%m%d%H%M%S)-$$"
|
||||
job_dir="$base/$job_id"
|
||||
mkdir "$job_dir"
|
||||
printf '%s' '${encoded}' | base64 -d > "$job_dir/payload.json"
|
||||
cat > "$job_dir/run.sh" <<'RUNNER'
|
||||
${remoteRunner()}
|
||||
RUNNER
|
||||
chmod 700 "$job_dir/run.sh"
|
||||
printf '{"status":"queued","stage":"created","jobId":"%s"}\\n' "$job_id" > "$job_dir/state.json"
|
||||
nohup "$job_dir/run.sh" "$job_dir" > "$job_dir/run.log" 2>&1 &
|
||||
printf '{"ok":true,"jobId":"%s","status":"queued"}\\n' "$job_id"
|
||||
ROOT
|
||||
`;
|
||||
}
|
||||
|
||||
function remoteRunner(): string {
|
||||
return String.raw`#!/bin/sh
|
||||
set -eu
|
||||
job_dir="$1"
|
||||
payload="$job_dir/payload.json"
|
||||
state="$job_dir/state.json"
|
||||
result="$job_dir/result.json"
|
||||
|
||||
json_get() {
|
||||
python3 - "$payload" "$1" <<'PY'
|
||||
import json, sys
|
||||
value=json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
for part in sys.argv[2].split("."):
|
||||
value=value[part]
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
write_state() {
|
||||
status="$1"
|
||||
stage="$2"
|
||||
exit_code=""
|
||||
if [ "$#" -ge 3 ]; then exit_code="$3"; fi
|
||||
python3 - "$state" "$status" "$stage" "$exit_code" <<'PY'
|
||||
import datetime, json, os, sys
|
||||
path,status,stage,exit_code=sys.argv[1:5]
|
||||
try:
|
||||
current=json.load(open(path, encoding="utf-8"))
|
||||
except Exception:
|
||||
current={}
|
||||
current.update({"status":status,"stage":stage,"updatedAt":datetime.datetime.now(datetime.timezone.utc).isoformat()})
|
||||
if status == "running" and not current.get("startedAt"):
|
||||
current["startedAt"]=current["updatedAt"]
|
||||
if status in {"succeeded","failed"}:
|
||||
current["finishedAt"]=current["updatedAt"]
|
||||
current["exitCode"]=int(exit_code or ("0" if status == "succeeded" else "1"))
|
||||
tmp=path+".tmp"
|
||||
json.dump(current, open(tmp,"w"), separators=(",",":"))
|
||||
os.replace(tmp,path)
|
||||
PY
|
||||
}
|
||||
|
||||
fail() {
|
||||
rc="$1"
|
||||
stage="$2"
|
||||
write_state failed "$stage" "$rc"
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
run_stage() {
|
||||
stage="$1"
|
||||
shift
|
||||
write_state running "$stage"
|
||||
"$@" || fail "$?" "$stage"
|
||||
}
|
||||
|
||||
PATH_DECLARED="$(json_get path)"
|
||||
EXPANSION_PATH="$(json_get expansionPath)"
|
||||
DESIRED_BYTES="$(json_get desiredBytes)"
|
||||
MIN_DISK_FREE_BYTES="$(json_get minDiskFreeBytes)"
|
||||
SWAPPINESS="$(json_get swappiness)"
|
||||
SYSCTL_FILE="$(json_get sysctlFile)"
|
||||
write_state running preflight
|
||||
case "$PATH_DECLARED" in /*) ;; *) fail 2 invalid-swap-path ;; esac
|
||||
case "$EXPANSION_PATH" in /*) ;; *) fail 2 invalid-expansion-path ;; esac
|
||||
case "$SYSCTL_FILE" in /etc/sysctl.d/*.conf) ;; *) fail 2 invalid-sysctl-path ;; esac
|
||||
if [ -L "$PATH_DECLARED" ] || [ -L "$EXPANSION_PATH" ]; then fail 2 swap-path-symlink; fi
|
||||
available_bytes="$(df -B1 --output=avail "$PATH_DECLARED" 2>/dev/null | tail -1 | tr -d ' ' || df -B1 --output=avail / | tail -1 | tr -d ' ')"
|
||||
declared_capacity_bytes=0
|
||||
if awk -v path="$PATH_DECLARED" 'NR>1 && $1==path {found=1} END {exit found?0:1}' /proc/swaps && [ -f "$PATH_DECLARED" ]; then
|
||||
declared_capacity_bytes="$((declared_capacity_bytes + $(stat -c %s "$PATH_DECLARED")))"
|
||||
fi
|
||||
if awk -v path="$EXPANSION_PATH" 'NR>1 && $1==path {found=1} END {exit found?0:1}' /proc/swaps && [ -f "$EXPANSION_PATH" ]; then
|
||||
declared_capacity_bytes="$((declared_capacity_bytes + $(stat -c %s "$EXPANSION_PATH")))"
|
||||
fi
|
||||
missing_bytes="$((DESIRED_BYTES - declared_capacity_bytes))"
|
||||
|
||||
if [ "$missing_bytes" -le 0 ]; then
|
||||
write_state running persist-policy
|
||||
else
|
||||
required_bytes="$((missing_bytes + MIN_DISK_FREE_BYTES))"
|
||||
if [ "$available_bytes" -lt "$required_bytes" ]; then fail 4 insufficient-disk-for-safe-expansion; fi
|
||||
if [ -e "$EXPANSION_PATH" ]; then fail 3 expansion-path-already-exists; fi
|
||||
write_state running allocate-extension
|
||||
if ! fallocate -l "$missing_bytes" "$EXPANSION_PATH"; then
|
||||
rm -f "$EXPANSION_PATH"
|
||||
fail 5 allocate-extension
|
||||
fi
|
||||
chmod 600 "$EXPANSION_PATH"
|
||||
if ! mkswap "$EXPANSION_PATH"; then
|
||||
rm -f "$EXPANSION_PATH"
|
||||
fail 6 format-extension
|
||||
fi
|
||||
if ! swapon "$EXPANSION_PATH"; then
|
||||
rm -f "$EXPANSION_PATH"
|
||||
fail 7 enable-extension
|
||||
fi
|
||||
fi
|
||||
|
||||
write_state running persist-policy
|
||||
touch /etc/fstab
|
||||
awk -v path="$PATH_DECLARED" '$1==path && $3=="swap" {found=1} END {exit found?0:1}' /etc/fstab || printf '%s none swap sw 0 0\n' "$PATH_DECLARED" >> /etc/fstab
|
||||
if awk -v path="$EXPANSION_PATH" 'NR>1 && $1==path {found=1} END {exit found?0:1}' /proc/swaps; then
|
||||
awk -v path="$EXPANSION_PATH" '$1==path && $3=="swap" {found=1} END {exit found?0:1}' /etc/fstab || printf '%s none swap sw 0 0\n' "$EXPANSION_PATH" >> /etc/fstab
|
||||
fi
|
||||
tmp_sysctl="$SYSCTL_FILE.tmp"
|
||||
printf 'vm.swappiness = %s\n' "$SWAPPINESS" > "$tmp_sysctl"
|
||||
chmod 644 "$tmp_sysctl"
|
||||
mv "$tmp_sysctl" "$SYSCTL_FILE"
|
||||
sysctl -q -w "vm.swappiness=$SWAPPINESS"
|
||||
|
||||
write_state running verify
|
||||
if ! python3 - "$PATH_DECLARED" "$EXPANSION_PATH" "$DESIRED_BYTES" "$SWAPPINESS" "$SYSCTL_FILE" > "$result" <<'PY'
|
||||
import json, os, stat, sys
|
||||
path,expansion_path,desired_raw,swappiness_raw,sysctl_file=sys.argv[1:6]
|
||||
desired=int(desired_raw)
|
||||
swappiness=int(swappiness_raw)
|
||||
swaps=[]
|
||||
with open("/proc/swaps", encoding="utf-8") as handle:
|
||||
for line in list(handle)[1:]:
|
||||
fields=line.split()
|
||||
if len(fields) >= 5:
|
||||
swaps.append({"path":fields[0],"sizeBytes":int(fields[2])*1024,"usedBytes":int(fields[3])*1024,"priority":int(fields[4])})
|
||||
configured=next((item for item in swaps if item["path"] == path), None)
|
||||
extension=next((item for item in swaps if item["path"] == expansion_path), None)
|
||||
runtime_swappiness=int(open("/proc/sys/vm/swappiness", encoding="utf-8").read().strip())
|
||||
declared_capacity=sum(os.stat(item).st_size for item in (path, expansion_path) if os.path.isfile(item) and any(swap["path"] == item for swap in swaps))
|
||||
result={
|
||||
"ok": configured is not None and declared_capacity >= desired and runtime_swappiness == swappiness,
|
||||
"path": path,
|
||||
"desiredBytes": desired,
|
||||
"configured": configured,
|
||||
"extension": extension,
|
||||
"totalSwapBytes": sum(item["sizeBytes"] for item in swaps),
|
||||
"declaredCapacityBytes": declared_capacity,
|
||||
"runtimeSwappiness": runtime_swappiness,
|
||||
"sysctlFile": sysctl_file,
|
||||
"sysctlFileExists": os.path.isfile(sysctl_file),
|
||||
"mode": oct(stat.S_IMODE(os.stat(path).st_mode))[2:] if os.path.isfile(path) else None,
|
||||
}
|
||||
print(json.dumps(result, separators=(",",":")))
|
||||
raise SystemExit(0 if result["ok"] else 8)
|
||||
PY
|
||||
then
|
||||
fail 8 verify
|
||||
fi
|
||||
write_state succeeded complete 0
|
||||
`;
|
||||
}
|
||||
|
||||
function factsScript(encoded: string): string {
|
||||
return String.raw`
|
||||
set -eu
|
||||
payload="$(mktemp)"
|
||||
trap 'rm -f "$payload"' EXIT
|
||||
printf '%s' '${encoded}' | base64 -d > "$payload"
|
||||
python3 - "$payload" <<'PY'
|
||||
import json, os, stat, sys
|
||||
spec=json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
path=spec["path"]
|
||||
expansion_path=spec["expansionPath"]
|
||||
sysctl_file=spec["sysctlFile"]
|
||||
swaps=[]
|
||||
with open("/proc/swaps", encoding="utf-8") as handle:
|
||||
for line in list(handle)[1:]:
|
||||
fields=line.split()
|
||||
if len(fields) >= 5:
|
||||
swaps.append({"path":fields[0],"sizeBytes":int(fields[2])*1024,"usedBytes":int(fields[3])*1024,"priority":int(fields[4])})
|
||||
mem={}
|
||||
with open("/proc/meminfo", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if ":" not in line: continue
|
||||
key,value=line.split(":",1)
|
||||
fields=value.split()
|
||||
if fields and fields[0].isdigit(): mem[key]=int(fields[0])*1024
|
||||
disk=os.statvfs(path if os.path.exists(path) else os.path.dirname(path) or "/")
|
||||
fstab=False
|
||||
expansion_fstab=False
|
||||
try:
|
||||
with open("/etc/fstab", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
fields=line.split()
|
||||
if len(fields) >= 3 and fields[0] == path and fields[2] == "swap": fstab=True
|
||||
if len(fields) >= 3 and fields[0] == expansion_path and fields[2] == "swap": expansion_fstab=True
|
||||
except OSError:
|
||||
pass
|
||||
sysctl_persisted=None
|
||||
try:
|
||||
for line in open(sysctl_file, encoding="utf-8"):
|
||||
if line.strip().startswith("vm.swappiness"):
|
||||
sysctl_persisted=int(line.split("=",1)[1].strip())
|
||||
except OSError:
|
||||
pass
|
||||
result={
|
||||
"ok": True,
|
||||
"path": path,
|
||||
"configuredPathExists": os.path.isfile(path),
|
||||
"configuredPathSizeBytes": os.stat(path).st_size if os.path.isfile(path) else None,
|
||||
"configuredPathMode": oct(stat.S_IMODE(os.stat(path).st_mode))[2:] if os.path.isfile(path) else None,
|
||||
"configuredPathActive": any(item["path"] == path for item in swaps),
|
||||
"expansionPath": expansion_path,
|
||||
"expansionPathExists": os.path.isfile(expansion_path),
|
||||
"expansionPathSizeBytes": os.stat(expansion_path).st_size if os.path.isfile(expansion_path) else None,
|
||||
"expansionPathMode": oct(stat.S_IMODE(os.stat(expansion_path).st_mode))[2:] if os.path.isfile(expansion_path) else None,
|
||||
"expansionPathActive": any(item["path"] == expansion_path for item in swaps),
|
||||
"activeSwaps": swaps,
|
||||
"declaredCapacityBytes": sum(os.stat(item).st_size for item in (path, expansion_path) if os.path.isfile(item) and any(swap["path"] == item for swap in swaps)),
|
||||
"swapTotalBytes": mem.get("SwapTotal",0),
|
||||
"swapFreeBytes": mem.get("SwapFree",0),
|
||||
"memAvailableBytes": mem.get("MemAvailable"),
|
||||
"diskAvailableBytes": disk.f_bavail * disk.f_frsize,
|
||||
"fstabPersisted": fstab,
|
||||
"expansionFstabPersisted": expansion_fstab,
|
||||
"runtimeSwappiness": int(open("/proc/sys/vm/swappiness", encoding="utf-8").read().strip()),
|
||||
"persistedSwappiness": sysctl_persisted,
|
||||
"sysctlFile": sysctl_file,
|
||||
"swapExcludedFromWebProbeAvailability": True,
|
||||
}
|
||||
print(json.dumps(result, separators=(",",":")))
|
||||
PY
|
||||
`;
|
||||
}
|
||||
|
||||
function parseByteSize(value: string): number {
|
||||
const match = value.trim().match(/^([1-9][0-9]*)([KMGT]i?B?|B)$/iu);
|
||||
if (match === null) throw new Error(`node.swap.size must use a positive size such as 64GiB; received ${value}`);
|
||||
const unit = match[2].toUpperCase().replace("IB", "").replace("B", "");
|
||||
const powers: Record<string, number> = { "": 0, K: 1, M: 2, G: 3, T: 4 };
|
||||
return Number(match[1]) * 1024 ** powers[unit];
|
||||
}
|
||||
|
||||
function isInSync(facts: Record<string, unknown> | null, declaration: HostSwapDeclaration, desiredBytes: number): boolean {
|
||||
return facts !== null
|
||||
&& facts.configuredPathActive === true
|
||||
&& numberValue(facts.declaredCapacityBytes) !== null
|
||||
&& (numberValue(facts.declaredCapacityBytes) ?? 0) >= desiredBytes
|
||||
&& facts.configuredPathMode === "600"
|
||||
&& facts.fstabPersisted === true
|
||||
&& (facts.expansionPathActive !== true || (facts.expansionPathMode === "600" && facts.expansionFstabPersisted === true))
|
||||
&& numberValue(facts.runtimeSwappiness) === declaration.swap.swappiness
|
||||
&& numberValue(facts.persistedSwappiness) === declaration.swap.swappiness;
|
||||
}
|
||||
|
||||
function desiredSummary(declaration: HostSwapDeclaration, desiredBytes: number): Record<string, unknown> {
|
||||
return {
|
||||
configSource: `${declaration.configPath}#node.swap`,
|
||||
ensure: declaration.swap.ensure,
|
||||
path: declaration.swap.path,
|
||||
expansionPath: declaration.swap.expansionPath,
|
||||
size: declaration.swap.size,
|
||||
sizeBytes: desiredBytes,
|
||||
swappiness: declaration.swap.swappiness,
|
||||
sysctlFile: declaration.swap.sysctlFile,
|
||||
};
|
||||
}
|
||||
|
||||
function compactFacts(facts: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (facts === null) return null;
|
||||
return {
|
||||
configuredPathExists: facts.configuredPathExists ?? null,
|
||||
configuredPathSizeBytes: facts.configuredPathSizeBytes ?? null,
|
||||
configuredPathMode: facts.configuredPathMode ?? null,
|
||||
configuredPathActive: facts.configuredPathActive ?? null,
|
||||
expansionPath: facts.expansionPath ?? null,
|
||||
expansionPathExists: facts.expansionPathExists ?? null,
|
||||
expansionPathSizeBytes: facts.expansionPathSizeBytes ?? null,
|
||||
expansionPathMode: facts.expansionPathMode ?? null,
|
||||
expansionPathActive: facts.expansionPathActive ?? null,
|
||||
activeSwaps: facts.activeSwaps ?? [],
|
||||
declaredCapacityBytes: facts.declaredCapacityBytes ?? null,
|
||||
swapTotalBytes: facts.swapTotalBytes ?? null,
|
||||
swapFreeBytes: facts.swapFreeBytes ?? null,
|
||||
memAvailableBytes: facts.memAvailableBytes ?? null,
|
||||
diskAvailableBytes: facts.diskAvailableBytes ?? null,
|
||||
fstabPersisted: facts.fstabPersisted ?? null,
|
||||
expansionFstabPersisted: facts.expansionFstabPersisted ?? null,
|
||||
runtimeSwappiness: facts.runtimeSwappiness ?? null,
|
||||
persistedSwappiness: facts.persistedSwappiness ?? null,
|
||||
sysctlFile: facts.sysctlFile ?? null,
|
||||
swapExcludedFromWebProbeAvailability: true,
|
||||
};
|
||||
}
|
||||
|
||||
function targetSummary(declaration: HostSwapDeclaration): Record<string, unknown> {
|
||||
return { node: declaration.nodeId, route: declaration.route, config: declaration.configPath };
|
||||
}
|
||||
|
||||
function statusCommand(declaration: HostSwapDeclaration): string {
|
||||
return `bun scripts/cli.ts platform-db postgres swap status --config ${declaration.configPath}`;
|
||||
}
|
||||
|
||||
function compactCapture(capture: SshCaptureResult, full: boolean): Record<string, unknown> {
|
||||
return {
|
||||
exitCode: capture.exitCode,
|
||||
stdoutBytes: Buffer.byteLength(capture.stdout, "utf8"),
|
||||
stderrBytes: Buffer.byteLength(capture.stderr, "utf8"),
|
||||
stdoutTail: full || capture.exitCode !== 0 ? capture.stdout.slice(-8000) : "",
|
||||
stderrTail: full || capture.exitCode !== 0 ? capture.stderr.slice(-4000) : "",
|
||||
};
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type LogicalDumpConfig,
|
||||
type LogicalDumpRuntimeFact,
|
||||
} from "./platform-db-postgres-backups";
|
||||
import { runPlatformDbHostSwapCommand, swapHelp, type HostSwapDeclaration } from "./platform-db-host-swap";
|
||||
|
||||
const defaultConfigPath = "config/platform-db/postgres-pk01.yaml";
|
||||
const managedHbaStart = "# BEGIN unidesk managed pk01-platform-postgres";
|
||||
@@ -61,6 +62,9 @@ interface PostgresHostConfig {
|
||||
ensure: boolean;
|
||||
size: string;
|
||||
path: string;
|
||||
expansionPath?: string;
|
||||
swappiness?: number;
|
||||
sysctlFile?: string;
|
||||
};
|
||||
};
|
||||
postgres: {
|
||||
@@ -310,17 +314,19 @@ export function platformDbHelp(): unknown {
|
||||
"bun scripts/cli.ts platform-db postgres monitor plan --config config/platform-db/postgres-nc01.yaml",
|
||||
"bun scripts/cli.ts platform-db postgres monitor status --config config/platform-db/postgres-nc01.yaml",
|
||||
"bun scripts/cli.ts platform-db postgres monitor reset --config config/platform-db/postgres-nc01.yaml --confirm",
|
||||
"bun scripts/cli.ts platform-db postgres swap plan|status|apply --config config/platform-db/postgres-nc01.yaml",
|
||||
],
|
||||
description: "Manage YAML-declared host-native PostgreSQL for UniDesk platform state. The PK01 declaration uses PostgreSQL 16 for Sub2API and does not deploy k3s.",
|
||||
description: "Manage YAML-declared host-native PostgreSQL and its host swap capacity for UniDesk platform state.",
|
||||
safety: {
|
||||
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.",
|
||||
apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote target 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.",
|
||||
swap: swapHelp(),
|
||||
redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.",
|
||||
noK3s: "PK01 PostgreSQL is host-systemd, not Docker or k3s.",
|
||||
noK3s: "The selected PostgreSQL declaration is host-systemd, not Docker or k3s.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -328,6 +334,10 @@ export function platformDbHelp(): unknown {
|
||||
export async function runPlatformDbCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
const [target, action = "plan"] = args;
|
||||
if (target !== "postgres") return unsupported(args);
|
||||
if (action === "swap") {
|
||||
const swapAction = args[2] ?? "plan";
|
||||
return await runPlatformDbHostSwapCommand(config, swapAction, args.slice(3), (configPath) => hostSwapDeclaration(readPostgresHostConfig(configPath)));
|
||||
}
|
||||
if (action === "monitor") {
|
||||
const monitorAction = args[2] ?? "plan";
|
||||
const options = parseOptions(args.slice(3));
|
||||
@@ -747,6 +757,9 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
|
||||
ensure: booleanField(swap, "ensure", `${configPath}.node.swap`),
|
||||
size: stringField(swap, "size", `${configPath}.node.swap`),
|
||||
path: absolutePathField(swap, "path", `${configPath}.node.swap`),
|
||||
...(swap.expansionPath === undefined ? {} : { expansionPath: absolutePathField(swap, "expansionPath", `${configPath}.node.swap`) }),
|
||||
...(swap.swappiness === undefined ? {} : { swappiness: integerField(swap, "swappiness", `${configPath}.node.swap`) }),
|
||||
...(swap.sysctlFile === undefined ? {} : { sysctlFile: absolutePathField(swap, "sysctlFile", `${configPath}.node.swap`) }),
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
@@ -822,6 +835,34 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function hostSwapDeclaration(pg: PostgresHostConfig): HostSwapDeclaration {
|
||||
const swappiness = pg.node.swap.swappiness;
|
||||
const sysctlFile = pg.node.swap.sysctlFile;
|
||||
const expansionPath = pg.node.swap.expansionPath;
|
||||
if (swappiness === undefined) throw new Error(`${pg.configPath}.node.swap.swappiness is required for controlled swap operations`);
|
||||
if (swappiness < 0 || swappiness > 200) throw new Error(`${pg.configPath}.node.swap.swappiness must be between 0 and 200`);
|
||||
if (sysctlFile === undefined) throw new Error(`${pg.configPath}.node.swap.sysctlFile is required for controlled swap operations`);
|
||||
if (expansionPath === undefined) throw new Error(`${pg.configPath}.node.swap.expansionPath is required for controlled swap operations`);
|
||||
if (expansionPath === pg.node.swap.path) throw new Error(`${pg.configPath}.node.swap.expansionPath must differ from path`);
|
||||
for (const [field, value] of [["path", pg.node.swap.path], ["expansionPath", expansionPath], ["sysctlFile", sysctlFile]] as const) {
|
||||
if (!/^\/[A-Za-z0-9._/-]+$/u.test(value)) throw new Error(`${pg.configPath}.node.swap.${field} must be a safe absolute host path`);
|
||||
}
|
||||
return {
|
||||
configPath: pg.configPath,
|
||||
nodeId: pg.node.id,
|
||||
route: pg.node.route,
|
||||
minDiskFreeGiB: pg.node.requiredHostFacts.minDiskFreeGiB,
|
||||
swap: {
|
||||
ensure: pg.node.swap.ensure,
|
||||
size: pg.node.swap.size,
|
||||
path: pg.node.swap.path,
|
||||
expansionPath,
|
||||
swappiness,
|
||||
sysctlFile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readPostgresLogicalDumps(pathArg: string): LogicalDumpConfig[] {
|
||||
return readPostgresHostConfig(pathArg).backup.logicalDumps;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user