id: kubernetes-pdb-maintenance-readiness
namespace: company.team
description: Check Deployment rollout readiness and PodDisruptionBudget capacity
before planned pod eviction; block when either signal is unsafe.
inputs:
- id: namespace
type: STRING
defaults: default
description: Namespace containing both the Deployment and PodDisruptionBudget.
- id: deployment_name
type: STRING
defaults: example-deployment
description: Exact name of the Deployment to inspect.
- id: pdb_name
type: STRING
defaults: example-pdb
description: Exact name of the matching PodDisruptionBudget to inspect.
- id: minimum_disruptions
type: INT
defaults: 1
description: Minimum currently allowed voluntary disruptions (1-20); usually 1
for serial maintenance.
triggers:
- id: maintenance_precheck
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 6 * * 1-5"
disabled: true
description: Disabled until real namespace and resource names are configured and
manual PASS/BLOCK paths tested.
tasks:
- id: get_deployments
type: io.kestra.plugin.kubernetes.kubectl.Get
description: Read full deployment specs and statuses through the Kubernetes API.
connection:
masterUrl: "{{ secret('K8S_MASTER_URL') }}"
oauthToken: "{{ secret('K8S_TOKEN') }}"
namespace: "{{ inputs.namespace }}"
resourceType: deployments
fetchType: STORE
- id: get_pdbs
type: io.kestra.plugin.kubernetes.kubectl.Get
description: Read full PodDisruptionBudget specs and statuses, including
disruptionsAllowed and generation.
connection:
masterUrl: "{{ secret('K8S_MASTER_URL') }}"
oauthToken: "{{ secret('K8S_TOKEN') }}"
namespace: "{{ inputs.namespace }}"
resourceType: poddisruptionbudgets
fetchType: STORE
- id: evaluate_readiness
type: io.kestra.plugin.scripts.node.Script
description: Select the named Deployment and PDB; check generation freshness,
rollout readiness, selector matching, and live eviction capacity.
containerImage: node:22-alpine
env:
NAMESPACE: "{{ inputs.namespace }}"
DEPLOYMENT: "{{ inputs.deployment_name }}"
PDB_NAME: "{{ inputs.pdb_name }}"
MINIMUM_DISRUPTIONS: "{{ inputs.minimum_disruptions }}"
inputFiles:
deployments.json: "{{ outputs.get_deployments.uri }}"
pdbs.json: "{{ outputs.get_pdbs.uri }}"
outputFiles:
- pdb-readiness-report.json
script: |
const fs = require('fs');
function decision(deploymentList,pdbList,opts){
function obj(x){return x&&typeof x==="object"&&!Array.isArray(x)}
function items(v){if(!obj(v)||!Array.isArray(v.items))throw Error("Expected Kubernetes List.items");if(v.items.length>500)throw Error("Too many objects; narrow the namespace");return v.items}
function nonNegInt(v){return Number.isSafeInteger(v)&&v>=0}
const reasons=[],dlist=items(deploymentList),plist=items(pdbList);
const dep=dlist.filter(x=>obj(x)&&x.metadata?.name===opts.deployment_name);
const pdb=plist.filter(x=>obj(x)&&x.metadata?.name===opts.pdb_name);
if(dep.length!==1)throw Error("Expected exactly one named Deployment");
if(pdb.length!==1)throw Error("Expected exactly one named PodDisruptionBudget");
const d=dep[0],p=pdb[0];
const desired=d.spec?.replicas??1, s=d.status??{}, ps=p.status??{}, selector=p.spec?.selector;
if(!nonNegInt(desired)||desired===0)reasons.push("Deployment has no positive replica target");
for(const [label,value] of [["Deployment generation",d.metadata?.generation],["Deployment observedGeneration",s.observedGeneration],["PDB generation",p.metadata?.generation],["PDB observedGeneration",ps.observedGeneration]]){
if(!nonNegInt(value))reasons.push(label+" missing or invalid");
}
if(nonNegInt(d.metadata?.generation)&&nonNegInt(s.observedGeneration)&&s.observedGeneration<d.metadata.generation)reasons.push("Deployment status has not observed the latest generation");
if(nonNegInt(p.metadata?.generation)&&nonNegInt(ps.observedGeneration)&&ps.observedGeneration!==p.metadata.generation)reasons.push("PDB status generation does not match object generation");
for(const label of ["readyReplicas","updatedReplicas","availableReplicas"]){
if(!nonNegInt(s[label])||s[label]<desired)reasons.push("Deployment "+label+" below desired replicas");
}
if(!obj(selector)||(obj(selector.matchLabels)&&!Object.keys(selector.matchLabels).length&&!Array.isArray(selector.matchExpressions))||(!obj(selector.matchLabels)&&!Array.isArray(selector.matchExpressions)))reasons.push("PDB selector is missing or empty");
const labels=d.spec?.template?.metadata?.labels;
if(!obj(labels))reasons.push("Deployment pod-template labels missing");
let matches=obj(labels)&&obj(selector);
if(matches){
for(const [k,v] of Object.entries(selector.matchLabels||{})){if(labels[k]!==v)matches=false}
for(const ex of selector.matchExpressions||[]){
if(!obj(ex)||typeof ex.key!=="string"||!["In","NotIn","Exists","DoesNotExist"].includes(ex.operator)){matches=false;break}
const exists=Object.prototype.hasOwnProperty.call(labels,ex.key),vals=ex.values;
if(ex.operator==="In"&&(!exists||!Array.isArray(vals)||vals.length===0||!vals.includes(labels[ex.key])))matches=false;
if(ex.operator==="NotIn"&&(!Array.isArray(vals)||vals.length===0||(exists&&vals.includes(labels[ex.key]))))matches=false;
if(ex.operator==="Exists"&&(!exists||(Array.isArray(vals)&&vals.length>0)))matches=false;
if(ex.operator==="DoesNotExist"&&(exists||(Array.isArray(vals)&&vals.length>0)))matches=false;
}
}
if(!matches)reasons.push("Selected PDB does not select Deployment pod-template labels");
const budget=ps.disruptionsAllowed,healthy=ps.currentHealthy,minimum=ps.desiredHealthy,expected=ps.expectedPods;
if(!nonNegInt(expected)||expected<desired)reasons.push("PDB expectedPods is missing or smaller than the workload replica target");
const allowedCondition=(ps.conditions||[]).find(x=>x&&x.type==="DisruptionAllowed");
if(allowedCondition&&allowedCondition.status!=="True")reasons.push("PDB DisruptionAllowed condition is not True");
if(!nonNegInt(budget)||!nonNegInt(healthy)||!nonNegInt(minimum))reasons.push("PDB disruption status is incomplete");
else{
if(healthy<minimum)reasons.push("PDB currentHealthy is below desiredHealthy");
if(budget<opts.minimum_disruptions)reasons.push("PDB has insufficient allowed disruptions");
}
return {status:reasons.length?"BLOCK":"PASS",namespace:opts.namespace,deployment:opts.deployment_name,pdb:opts.pdb_name,desired_replicas:desired,ready_replicas:s.readyReplicas??null,updated_replicas:s.updatedReplicas??null,available_replicas:s.availableReplicas??null,disruptions_allowed:budget??null,current_healthy:healthy??null,desired_healthy:minimum??null,minimum_disruptions:opts.minimum_disruptions,selector_matches:!!matches,reasons};
}
function load(filename) {
const doc = JSON.parse(fs.readFileSync(filename, 'utf8'));
return typeof doc === 'string' ? JSON.parse(doc) : doc;
}
function envName(name) {
const text = process.env[name];
if (!text || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/.test(text)) throw Error("Invalid " + name);
return text;
}
const min = Number(process.env.MINIMUM_DISRUPTIONS);
if (!Number.isSafeInteger(min) || min < 1 || min > 20) throw Error('minimum_disruptions must be 1-20');
const opts = {
namespace: envName('NAMESPACE'),
deployment_name: envName('DEPLOYMENT'),
pdb_name: envName('PDB_NAME'),
minimum_disruptions: min
};
const result = decision(load('deployments.json'), load('pdbs.json'), opts);
fs.writeFileSync('pdb-readiness-report.json', JSON.stringify(result, null, 2));
console.log('::' + JSON.stringify({ outputs: {
status: result.status,
reason_count: result.reasons.length,
disruptions_allowed: result.disruptions_allowed === null ? -1 : result.disruptions_allowed
}}) + '::');
console.log('PDB maintenance readiness: ' + result.status + ' (see report)');
- id: report_location
type: io.kestra.plugin.core.output.OutputValues
description: Publish status and evidence before failure gate.
values:
status: "{{ outputs.evaluate_readiness.vars.status }}"
report_uri: "{{
outputs.evaluate_readiness.outputFiles['pdb-readiness-report.json'] }}"
disruptions_allowed: "{{ outputs.evaluate_readiness.vars.disruptions_allowed }}"
- id: readiness_gate
type: io.kestra.plugin.core.flow.If
description: Block maintenance if the named PDB has no capacity or its
Deployment is not fully ready.
condition: "{{ outputs.evaluate_readiness.vars.status == 'BLOCK' }}"
then:
- id: send_block_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Send a short warning; check the saved report for all reasons.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text":"Kubernetes maintenance BLOCKED for {{ inputs.namespace }}/{{ inputs.deployment_name }} (PDB {{ inputs.pdb_name }}). Findings: {{ outputs.evaluate_readiness.vars.reason_count }}. Execution: {{ execution.id }}."}
- id: stop_unsafe_maintenance
type: io.kestra.plugin.core.execution.Fail
description: Return a failed execution so dependent maintenance tasks cannot
continue.
errorMessage: "Deployment/PDB preflight blocked maintenance. Review
pdb-readiness-report.json."
else:
- id: report_ready
type: io.kestra.plugin.core.log.Log
message: "Maintenance preflight PASS: {{ inputs.namespace }}/{{
inputs.deployment_name }}, PDB {{ inputs.pdb_name }}. Eviction
admission must still be checked at execution time."
errors:
- id: log_precheck_failure
type: io.kestra.plugin.core.log.Log
message: "PDB precheck failed or blocked; no approval for maintenance. Execution
{{ execution.id }}."