diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 6fde0b4b42e4..26b62c670e68 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -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 diff --git a/scripts/k8s/deploy.sh b/scripts/k8s/deploy.sh index abd62dedf587..2a2f47d8229c 100755 --- a/scripts/k8s/deploy.sh +++ b/scripts/k8s/deploy.sh @@ -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 # --------------------------------------------------------------------------- diff --git a/test/scripts/k8s-deploy.test.ts b/test/scripts/k8s-deploy.test.ts new file mode 100644 index 000000000000..055e076223f7 --- /dev/null +++ b/test/scripts/k8s-deploy.test.ts @@ -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"]); + }); +});