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>
This commit is contained in:
JackS1237
2026-08-02 18:02:09 +08:00
committed by GitHub
parent 1c9e78aea0
commit 0bc32f0f5c
3 changed files with 233 additions and 5 deletions
+17 -1
View File
@@ -163,7 +163,23 @@ This applies all manifests and restarts the pod to pick up any config or secret
./scripts/k8s/deploy.sh --delete
```
This deletes the namespace and all resources in it, including the PVC.
For the default `openclaw` namespace, this deletes the namespace and everything in it, including the PVC.
For a custom namespace, `--delete` removes only OpenClaw resources and preserves the namespace and unrelated workloads:
```bash
OPENCLAW_NAMESPACE=my-namespace ./scripts/k8s/deploy.sh --delete
```
Use `--delete-resources` to request this scoped teardown explicitly in any namespace. Both scoped modes delete the OpenClaw Deployment, Service, PVC, ConfigMap, and generated Secret. Deleting the PVC removes OpenClaw's claim and access to its persisted data; whether the backing volume and data are deleted depends on the PersistentVolume or StorageClass reclaim policy (`Delete` or `Retain`).
To delete a custom namespace and every workload in it, explicitly opt in:
```bash
OPENCLAW_NAMESPACE=my-namespace ./scripts/k8s/deploy.sh --delete-namespace
```
This also deletes unrelated workloads and the PVC.
## Architecture notes
+31 -4
View File
@@ -8,7 +8,9 @@
# ./scripts/k8s/deploy.sh # Deploy (requires API key in env or secret already in cluster)
# ./scripts/k8s/deploy.sh --create-secret # Create or update the K8s Secret from env vars
# ./scripts/k8s/deploy.sh --show-token # Print the gateway token after deploy
# ./scripts/k8s/deploy.sh --delete # Tear down
# ./scripts/k8s/deploy.sh --delete # Tear down safely for the selected namespace
# ./scripts/k8s/deploy.sh --delete-resources # Delete OpenClaw resources only
# ./scripts/k8s/deploy.sh --delete-namespace # Delete the namespace and all resources
#
# Environment:
# OPENCLAW_NAMESPACE Kubernetes namespace (default: openclaw)
@@ -34,7 +36,11 @@ Usage: ./scripts/k8s/deploy.sh [OPTION]
(no args) Deploy OpenClaw (creates secret from env if needed)
--create-secret Create or update the K8s Secret from env vars without deploying
--show-token Print the gateway token after deploy or secret creation
--delete Delete the namespace and all resources
--delete Delete the default namespace, or resources only in a custom namespace
--delete-resources
Delete OpenClaw resources from the namespace
--delete-namespace
Delete the namespace and all resources in it
-h, --help Show this help
Environment:
@@ -57,6 +63,12 @@ while [[ $# -gt 0 ]]; do
--delete)
MODE="delete"
;;
--delete-resources)
MODE="delete-resources"
;;
--delete-namespace)
MODE="delete-namespace"
;;
--show-token)
SHOW_TOKEN=true
;;
@@ -70,15 +82,30 @@ while [[ $# -gt 0 ]]; do
done
# ---------------------------------------------------------------------------
# --delete
# --delete / --delete-namespace
# ---------------------------------------------------------------------------
if [[ "$MODE" == "delete" ]]; then
if [[ "$MODE" == "delete" && "$NS" != "openclaw" ]]; then
MODE="delete-resources"
fi
if [[ "$MODE" == "delete" || "$MODE" == "delete-namespace" ]]; then
echo "Deleting namespace '$NS' and all resources..."
kubectl delete namespace "$NS" --ignore-not-found
echo "Done."
exit 0
fi
# ---------------------------------------------------------------------------
# --delete-resources
# ---------------------------------------------------------------------------
if [[ "$MODE" == "delete-resources" ]]; then
echo "Deleting OpenClaw resources from namespace '$NS'..."
kubectl delete -k "$MANIFESTS" -n "$NS" --ignore-not-found
kubectl delete secret openclaw-secrets -n "$NS" --ignore-not-found
echo "Done."
exit 0
fi
# ---------------------------------------------------------------------------
# Create and apply Secret to the cluster
# ---------------------------------------------------------------------------
+185
View File
@@ -0,0 +1,185 @@
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"]);
});
});