Schedule icon
Commands icon
Docker icon
OutputValues icon
Loop icon
If icon
Request icon
Log icon
SlackIncomingWebhook icon
Fail icon

Sweep Ephemeral Test and Preview Namespaces by Pull Request Status and TTL

Delete stale Kubernetes preview namespaces when pull requests close or TTL expires, with dry-run safety, blast radius caps, and Slack notifications.

Categories
Infrastructure

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.

How it works

  1. 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.
  2. 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.
  3. filter_candidates (io.kestra.plugin.core.output.OutputValues) reads namespaces.json and applies client-side safety filters using Pebble and jq:
    • Requires the namespace name to match inputs.name_regex (default ^pr-[0-9]+$).
    • Hard-excludes core system namespaces (kube-system, kube-public, kube-node-lease, and default) even if labels or regex match.
    • Discards namespaces currently in Terminating phase (deletionTimestamp != null).
    • Enforces inputs.min_age_hours (default 1 hour) so freshly provisioned preview namespaces are never touched before testing begins.
    • Calculates age in hours and extracts the pull request number, sorting candidates oldest first.
  4. evaluate_candidates_loop (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) processes each candidate sequentially:
    • If the namespace name contains pull request digits, 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.
  5. 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).
  6. execute_cleanup (io.kestra.plugin.core.flow.If) branches on inputs.dry_run (default true):
    • When 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.
    • When 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.
  7. 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.
  8. 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.

What you get

  • Automated FinOps reclamation of idle preview environments, eliminating stranded compute, memory, load balancers, and volumes.
  • Opt-in protection: only namespaces carrying the configured preview label and matching the regex pattern can ever be evaluated, and the label is re-verified immediately before each delete.
  • Blast radius limit: max_deletions_per_run prevents accidental bulk deletion if an upstream Git repository changes state unexpectedly.
  • Grace period safety: min_age_hours guarantees newly created preview environments are protected against early sweep.
  • Correct handling of cluster-scoped resources: uses real kubectl so label selectors and namespace deletion work as documented by Kubernetes.
  • Transparent Slack summaries that explain exact reasons (pr_closed, pr_merged, ttl_exceeded) and deferred items.

Who it's for

  • Platform Engineers and DevOps teams managing shared development and staging Kubernetes clusters.
  • FinOps Practitioners looking to automate non-production resource cleanup and curtail cloud spend.
  • SREs managing ephemeral CI/CD test environments across multiple engineering squads.

Why orchestrate this with Kestra

  • Declarative and Auditable: Captures discovery, external Git API evaluation, rate limiting, and Kubernetes deletion in declarative version-controlled YAML.
  • Safe Dry-Run by Default: Ships with dry_run: true so platform engineers can test against real clusters and inspect proposed actions before enabling live deletions.
  • Single Notification Overhead: Aggregates all evaluations across the loop into one Slack digest instead of sending noisy per-namespace alerts.
  • Built-in Secret Masking: Credentials for cluster authentication and GitHub tokens are securely resolved and masked in execution logs.

Prerequisites

  • A reachable Kubernetes cluster (in-cluster or external API endpoint).
  • A Kestra worker able to run the Docker task runner (the flow uses the alpine/k8s image). To run on Kubernetes instead, swap the taskRunner for io.kestra.plugin.kubernetes.runner.Kubernetes.
  • A Kubernetes ServiceAccount with least-privilege permissions to get, list, and delete namespaces.
  • A GitHub Personal Access Token or GitHub App token with pull_requests:read permission.
  • A Slack Incoming Webhook URL.

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

Secrets

  • 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.

Quick start

  1. Add K8S_MASTER_URL, K8S_TOKEN, K8S_CA_CERT_DATA, GITHUB_TOKEN, and SLACK_WEBHOOK_URL to your Kestra namespace secrets.
  2. Label a test namespace to opt into sweeping:
    kubectl create namespace pr-999
    kubectl label namespace pr-999 preview=true
    
  3. Run this flow with default inputs (dry_run: true).
  4. Review execution logs and the Slack summary to verify that pr-999 is identified and marked with the expected reason.
  5. Execute with dry_run: false to perform live deletion, and confirm that the namespace and its resources are removed.
  6. Enable the sweep_schedule trigger to run automated sweeps every 2 hours.

How to extend

  • Custom Naming Conventions: Adjust name_regex to match custom environment prefixes such as ^test-feature-[0-9]+$.
  • GitLab or Bitbucket Integration: Replace the GitHub HTTP request task with GitLab Merge Request API endpoints.
  • Cost Reporting: Integrate with Kubecost API before deletion to record estimated dollar savings per deleted namespace.
  • Approval Gate: Add an io.kestra.plugin.core.flow.Pause task between plan_actions and execute_cleanup for human-in-the-loop review of deletions in critical clusters.

Limits and Operational Considerations

  • Cascade Scope: Deleting a Kubernetes namespace terminates all namespaced objects within it, including Pods, ReplicaSets, Services, and PVCs. Cloud controller managers will deprovision associated external LoadBalancers. The sweeper does not perform node cordoning because cordoning applies to worker nodes, not namespaces.
  • Asynchronous Deletion: Namespace deletion runs with --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.
  • PersistentVolume Retention: PersistentVolumes configured with a reclaimPolicy of Retain will transition to Released upon PVC deletion and will remain on storage until manually reclaimed.
  • Static Schedule: The trigger runs on a fixed cron schedule; namespace deletion occurs on the next sweep following pull request closure.
  • GitHub Rate Limits: Authenticated GitHub API requests permit up to 5,000 requests per hour per token. The blast radius cap and concurrency limit prevent rate-limit exhaustion.
  • Difference from Single-Environment Lifecycle Gate: This flow differs fundamentally from 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.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.