New to Kestra?
Use blueprints to kickstart your first workflows.
Delete stale Kubernetes preview namespaces when pull requests close or TTL expires, with dry-run safety, blast radius caps, and Slack notifications.
Orchestrates cluster-wide FinOps hygiene and resource reclamation for Kubernetes dynamic preview and ephemeral test environments. Pull request preview namespaces often accumulate when pull requests are merged, closed, or abandoned without triggering CI cleanup hooks. Lingering preview namespaces continuously consume cluster CPU and memory reservations, block PersistentVolumeClaims, hold cloud LoadBalancer IP allocations, and inflate cloud infrastructure costs.
This workflow runs kubectl to discover labeled preview namespaces, queries the GitHub Pull Request REST API to inspect pull request lifecycle states, enforces a minimum age grace period and maximum TTL, serializes evaluation, caps deletions per run, supports dry-run inspection, and publishes an aggregated Slack report.
sweep_schedule (io.kestra.plugin.core.trigger.Schedule) triggers the workflow on a recurring 2-hour schedule (0 */2 * * *). The trigger ships disabled by default for safe initial manual testing. Flow-level concurrency is configured with limit: 1 and behavior: QUEUE so concurrent runs never conflict.list_namespaces (io.kestra.plugin.scripts.shell.Commands) runs kubectl get namespaces -l <selector> inside an alpine/k8s container, authenticating with the ServiceAccount token and CA data from secrets. The label selector (default preview=true) is applied server-side by the Kubernetes API, and the result is written to namespaces.json as a compact list of name, creationTimestamp, and deletionTimestamp. A script task is used because namespaces are cluster-scoped and the native kubectl.Get and kubectl.Delete tasks neither support label selectors nor cluster-scoped resources.filter_candidates (io.kestra.plugin.core.output.OutputValues) reads namespaces.json and applies client-side safety filters using Pebble and jq:inputs.name_regex (default ^pr-[0-9]+$).kube-system, kube-public, kube-node-lease, and default) even if labels or regex match.Terminating phase (deletionTimestamp != null).inputs.min_age_hours (default 1 hour) so freshly provisioned preview namespaces are never touched before testing begins.evaluate_candidates_loop (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) processes each candidate sequentially:check_github_pr (io.kestra.plugin.core.flow.If) executes get_pr (io.kestra.plugin.core.http.Request) against the GitHub API (GET /repos/{owner}/{repo}/pulls/{number}) with allowFailed: true and transient error retries.record_verdict evaluates qualifications: pull request merged (pr_merged) or closed (pr_closed) qualifies for immediate deletion. Active open pull requests older than inputs.max_ttl_hours (default 72 hours) qualify under ttl_exceeded. Missing pull requests (HTTP 404) fall back to the TTL rule. Transient or authentication API errors (non-200, non-404) mark the namespace with github_api_error and prevent deletion. Namespaces without pull request numbers evaluate against TTL only.plan_actions (io.kestra.plugin.core.output.OutputValues) collects loop outputs via loopOutputs(). It partitions qualifying namespaces into to_delete (up to inputs.max_deletions_per_run, default 5) and deferred (any qualifying namespaces exceeding the cap).execute_cleanup (io.kestra.plugin.core.flow.If) branches on inputs.dry_run (default true):dry_run is true, log_dry_run_plan logs each namespace that would be deleted, along with its age and reason, making zero cluster modifications.dry_run is false, delete_qualifying_namespaces (io.kestra.plugin.scripts.shell.Commands) runs kubectl delete namespace for each target. Before each delete it re-checks that the namespace still exists and still matches the label selector, and --ignore-not-found ensures namespaces removed concurrently do not fail the flow. allowFailure: true lets the Slack summary still be delivered if a delete fails.notify_slack_summary (io.kestra.plugin.core.flow.If) evaluates whether any namespaces qualified for deletion, were planned in dry run, were deferred by cap, or encountered API errors. If so, send_slack_report (io.kestra.plugin.slack.notifications.SlackIncomingWebhook) dispatches a single comprehensive Slack message detailing target namespaces, ages, pull request states, reasons, and cap deferrals. When no namespaces qualify, the workflow remains completely quiet.fail_on_github_errors (io.kestra.plugin.core.execution.Fail) fails the execution after the Slack notification if any GitHub API calls failed, alerting on-call engineers to credential or rate-limit issues while ensuring the summary is delivered.max_deletions_per_run prevents accidental bulk deletion if an upstream Git repository changes state unexpectedly.min_age_hours guarantees newly created preview environments are protected against early sweep.kubectl so label selectors and namespace deletion work as documented by Kubernetes.pr_closed, pr_merged, ttl_exceeded) and deferred items.dry_run: true so platform engineers can test against real clusters and inspect proposed actions before enabling live deletions.alpine/k8s image). To run on Kubernetes instead, swap the taskRunner for io.kestra.plugin.kubernetes.runner.Kubernetes.pull_requests:read permission.Apply this least-privilege ClusterRole and ClusterRoleBinding for the sweeper ServiceAccount:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kestra-namespace-sweeper
rules:
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kestra-namespace-sweeper-binding
subjects:
- kind: ServiceAccount
name: kestra-sweeper-sa
namespace: default
roleRef:
kind: ClusterRole
name: kestra-namespace-sweeper
apiGroup: rbac.authorization.k8s.io
K8S_MASTER_URL: Kubernetes API server endpoint (e.g. https://kubernetes.default.svc or https://k8s.example.com:6443).K8S_TOKEN: Bearer token for the sweeper ServiceAccount.K8S_CA_CERT_DATA: Base64-encoded cluster certificate authority data.GITHUB_TOKEN: GitHub token with read access to repository pull requests.SLACK_WEBHOOK_URL: Slack Incoming Webhook URL for cleanup digests.K8S_MASTER_URL, K8S_TOKEN, K8S_CA_CERT_DATA, GITHUB_TOKEN, and SLACK_WEBHOOK_URL to your Kestra namespace secrets.kubectl create namespace pr-999
kubectl label namespace pr-999 preview=true
dry_run: true).pr-999 is identified and marked with the expected reason.dry_run: false to perform live deletion, and confirm that the namespace and its resources are removed.sweep_schedule trigger to run automated sweeps every 2 hours.name_regex to match custom environment prefixes such as ^test-feature-[0-9]+$.io.kestra.plugin.core.flow.Pause task between plan_actions and execute_cleanup for human-in-the-loop review of deletions in critical clusters.--wait=false, so a namespace may remain in Terminating for a while (for example when finalizers are pending). Terminating namespaces are ignored on the next sweep.reclaimPolicy of Retain will transition to Released upon PVC deletion and will remain on storage until manually reclaimed.helm-preview-environment-lifecycle-and-ephemeral-teardown-gate.yaml. That blueprint deploys, tests, and uninstalls a single preview environment within one continuous CI-triggered run. This blueprint is an autonomous, cluster-wide FinOps sweeper that periodically cleans up abandoned or unmanaged preview namespaces across teams.