Files
openclaw/test/scripts/k8s-deploy.test.ts
JackS1237 0bc32f0f5c fix: add Kubernetes resource-only teardown (#114953)
* fix: add Kubernetes resource-only teardown

* fix(k8s): preserve shared namespaces on teardown

---------

Co-authored-by: Peter Steinberger <steipete@macos.shared>
2026-08-02 03:02:09 -07:00

186 lines
6.7 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function writeExecutable(filePath: string, contents: string): void {
writeFileSync(filePath, contents);
chmodSync(filePath, 0o755);
}
function runWithStubbedKubectl(
args: string[],
namespace: string,
options: {
deleteKustomizeStatus?: number;
deleteNamespaceStatus?: number;
deleteSecretStatus?: number;
} = {},
) {
const root = tempDirs.make("openclaw-k8s-delete-");
const binDir = path.join(root, "bin");
const logPath = path.join(root, "kubectl.log");
mkdirSync(binDir);
writeExecutable(
path.join(binDir, "kubectl"),
`#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$OPENCLAW_KUBECTL_LOG"
if [[ "$1" == "delete" && "$2" == "-k" ]]; then
exit "\${OPENCLAW_KUBECTL_DELETE_KUSTOMIZE_STATUS:-0}"
fi
if [[ "$1" == "delete" && "$2" == "namespace" ]]; then
exit "\${OPENCLAW_KUBECTL_DELETE_NAMESPACE_STATUS:-0}"
fi
if [[ "$1" == "delete" && "$2" == "secret" ]]; then
exit "\${OPENCLAW_KUBECTL_DELETE_SECRET_STATUS:-0}"
fi
if [[ "$1" == "get" && "$2" == "namespace" ]]; then
exit 99
fi
case "$1" in
cluster-info|delete) exit 0 ;;
*) exit 99 ;;
esac
`,
);
writeExecutable(path.join(binDir, "openssl"), "#!/usr/bin/env bash\nexit 0\n");
const result = spawnSync("bash", ["scripts/k8s/deploy.sh", ...args], {
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
OPENCLAW_KUBECTL_DELETE_KUSTOMIZE_STATUS: String(options.deleteKustomizeStatus ?? 0),
OPENCLAW_KUBECTL_DELETE_NAMESPACE_STATUS: String(options.deleteNamespaceStatus ?? 0),
OPENCLAW_KUBECTL_DELETE_SECRET_STATUS: String(options.deleteSecretStatus ?? 0),
OPENCLAW_KUBECTL_LOG: logPath,
OPENCLAW_NAMESPACE: namespace,
PATH: `${binDir}:${process.env.PATH ?? ""}`,
},
});
return {
calls: readFileSync(logPath, "utf8").trim().split("\n"),
output: `${result.stdout}\n${result.stderr}`,
result,
};
}
describe("scripts/k8s/deploy.sh", () => {
function runDeleteResourcesWithStubbedKubectl(
namespace: string,
options: {
deleteKustomizeStatus?: number;
} = {},
) {
return runWithStubbedKubectl(["--delete-resources"], namespace, options);
}
it("keeps the default namespace delete mode as a full namespace teardown", () => {
const { calls, output, result } = runWithStubbedKubectl(["--delete"], "openclaw");
expect(result.status, output).toBe(0);
expect(output).toContain("Deleting namespace 'openclaw' and all resources");
expect(calls).toEqual(["cluster-info", "delete namespace openclaw --ignore-not-found"]);
});
it("keeps a custom namespace and unrelated workloads when the legacy delete mode is used", () => {
const { calls, output, result } = runWithStubbedKubectl(["--delete"], "my-namespace");
expect(result.status, output).toBe(0);
expect(output).toContain("Deleting OpenClaw resources from namespace 'my-namespace'");
expect(calls).toEqual([
"cluster-info",
`delete -k ${path.resolve("scripts/k8s/manifests")} -n my-namespace --ignore-not-found`,
"delete secret openclaw-secrets -n my-namespace --ignore-not-found",
]);
expect(calls).not.toContain("delete namespace my-namespace --ignore-not-found");
expect(calls).not.toContain("get namespace my-namespace");
});
it("deletes OpenClaw resources without deleting the namespace", () => {
const { calls, output, result } = runDeleteResourcesWithStubbedKubectl("my-namespace");
expect(result.status, output).toBe(0);
expect(output).toContain("Deleting OpenClaw resources from namespace 'my-namespace'");
expect(calls).toEqual([
"cluster-info",
`delete -k ${path.resolve("scripts/k8s/manifests")} -n my-namespace --ignore-not-found`,
"delete secret openclaw-secrets -n my-namespace --ignore-not-found",
]);
expect(calls).not.toContain("delete namespace my-namespace --ignore-not-found");
expect(calls).not.toContain("get namespace my-namespace");
});
it("surfaces resource delete failures instead of reporting teardown success", () => {
const { calls, output, result } = runDeleteResourcesWithStubbedKubectl("restricted-namespace", {
deleteKustomizeStatus: 17,
});
expect(result.status, output).toBe(17);
expect(output).toContain("Deleting OpenClaw resources from namespace 'restricted-namespace'");
expect(calls).toEqual([
"cluster-info",
`delete -k ${path.resolve("scripts/k8s/manifests")} -n restricted-namespace --ignore-not-found`,
]);
expect(output).not.toContain("Done.");
});
it("stops custom namespace teardown when deleting managed manifests fails", () => {
const { calls, output, result } = runWithStubbedKubectl(["--delete"], "shared-namespace", {
deleteKustomizeStatus: 17,
});
expect(result.status, output).toBe(17);
expect(calls).toEqual([
"cluster-info",
`delete -k ${path.resolve("scripts/k8s/manifests")} -n shared-namespace --ignore-not-found`,
]);
expect(output).not.toContain("Done.");
});
it.each(["--delete", "--delete-resources"])(
"surfaces generated Secret deletion failures for %s",
(mode) => {
const { calls, output, result } = runWithStubbedKubectl([mode], "shared-namespace", {
deleteSecretStatus: 23,
});
expect(result.status, output).toBe(23);
expect(calls).toEqual([
"cluster-info",
`delete -k ${path.resolve("scripts/k8s/manifests")} -n shared-namespace --ignore-not-found`,
"delete secret openclaw-secrets -n shared-namespace --ignore-not-found",
]);
expect(output).not.toContain("Done.");
},
);
it("surfaces namespace deletion failures instead of reporting teardown success", () => {
const { calls, output, result } = runWithStubbedKubectl(["--delete-namespace"], "shared", {
deleteNamespaceStatus: 29,
});
expect(result.status, output).toBe(29);
expect(calls).toEqual(["cluster-info", "delete namespace shared --ignore-not-found"]);
expect(output).not.toContain("Done.");
});
it("deletes the namespace only when the explicit namespace teardown mode is selected", () => {
const { calls, output, result } = runWithStubbedKubectl(
["--delete-namespace"],
"shared-namespace",
);
expect(result.status, output).toBe(0);
expect(output).toContain("Deleting namespace 'shared-namespace' and all resources");
expect(calls).toEqual(["cluster-info", "delete namespace shared-namespace --ignore-not-found"]);
});
});