id: "kubernetes-hpa-scaling-limit-guard"
namespace: "company.team"
description: "Check a named HPA for scaling failures, stale status, and a
reported scaling limit. Save evidence and block downstream work when unsafe."
inputs:
- id: "namespace"
type: "STRING"
defaults: "default"
description: "Kubernetes namespace with the target HorizontalPodAutoscaler."
- id: "hpa_name"
type: "STRING"
defaults: "example-api"
description: "Exact HorizontalPodAutoscaler name."
triggers:
- id: "periodic_check"
type: "io.kestra.plugin.core.trigger.Schedule"
cron: "*/15 * * * *"
disabled: true
description: "Enable only after real HPA values and alert delivery have been checked."
tasks:
- id: "read_hpas"
type: "io.kestra.plugin.kubernetes.kubectl.Get"
description: "Fetch full autoscaling/v2 HPA state with read-only API access."
connection:
masterUrl: "{{ secret('K8S_MASTER_URL') }}"
oauthToken: "{{ secret('K8S_TOKEN') }}"
namespace: "{{ inputs.namespace }}"
resourceType: "horizontalpodautoscalers"
apiGroup: "autoscaling"
apiVersion: "v2"
fetchType: "STORE"
- id: "check_scaling"
type: "io.kestra.plugin.scripts.node.Script"
description: "Check controller freshness and AbleToScale, ScalingActive, and
ScalingLimited conditions."
containerImage: "node:22-alpine"
env:
HPA_NAME: "{{ inputs.hpa_name }}"
K8S_NAMESPACE: "{{ inputs.namespace }}"
inputFiles:
hpas.json: "{{ outputs.read_hpas.uri }}"
outputFiles:
- "hpa-scaling-report.json"
script: |
function evaluateHPA(data, opts) {
const reasons = [];
const obj = x => x !== null && typeof x === "object" && !Array.isArray(x);
const int = x => Number.isSafeInteger(x) && x >= 0;
if (!obj(data) || !Array.isArray(data.items) || data.items.length > 500) {
return {status:"BLOCK", reasons:["Kubernetes API did not return a bounded HPA list"]};
}
const found = data.items.filter(x => obj(x) && x.metadata?.name === opts.hpa_name && x.metadata?.namespace === opts.namespace);
if (found.length !== 1) return {status:"BLOCK", reasons:["Expected one HPA with the configured name and namespace"], hpa:opts.hpa_name, namespace:opts.namespace};
const h = found[0], spec=h.spec || {}, status=h.status || {}, generation=h.metadata?.generation;
const min=spec.minReplicas ?? 1, max=spec.maxReplicas, current=status.currentReplicas, desired=status.desiredReplicas;
if (h.kind !== "HorizontalPodAutoscaler" || h.apiVersion !== "autoscaling/v2") reasons.push("Expected autoscaling/v2 HorizontalPodAutoscaler");
if (!int(generation) || generation === 0 || !int(status.observedGeneration) || status.observedGeneration < generation) reasons.push("HPA controller status is stale or incomplete");
if (!int(min) || min < 1 || !int(max) || max < min) reasons.push("HPA replica bounds are invalid");
if (!int(current) || !int(desired)) reasons.push("HPA replica status is missing or invalid");
if (!Array.isArray(status.conditions)) reasons.push("HPA conditions are missing");
const condition = name => {
if (!Array.isArray(status.conditions)) return null;
const matches = status.conditions.filter(x=>obj(x)&&x.type === name);
return matches.length === 1 && ["True","False","Unknown"].includes(matches[0].status) ? matches[0] : null;
};
const able=condition("AbleToScale"), active=condition("ScalingActive"), limited=condition("ScalingLimited");
if (!able || able.status !== "True") reasons.push("AbleToScale is not True");
if (!active || active.status !== "True") reasons.push("ScalingActive is not True");
if (!limited) reasons.push("ScalingLimited condition is missing or invalid");
else if (limited.status !== "False") reasons.push("ScalingLimited is " + limited.status + (limited.reason ? " ("+limited.reason+")" : ""));
if (int(max) && int(desired) && desired > max) reasons.push("Desired replicas exceed maxReplicas");
const summary={status:reasons.length?"BLOCK":"PASS", namespace:opts.namespace, hpa:opts.hpa_name,
min_replicas:int(min)?min:null, max_replicas:int(max)?max:null,
current_replicas:int(current)?current:null, desired_replicas:int(desired)?desired:null,
scaling_limited:limited?.status ?? null, scaling_limit_reason:limited?.reason ?? null, reasons};
return summary;
}
const fs = require("fs");
const opts={hpa_name:process.env.HPA_NAME,namespace:process.env.K8S_NAMESPACE};
if(!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(opts.hpa_name||"")||!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(opts.namespace||""))throw Error("Invalid HPA name or namespace");
const text=fs.readFileSync("hpas.json","utf8");
if(Buffer.byteLength(text)>1048576)throw Error("HPA response exceeds 1 MiB");
const raw=JSON.parse(text),doc=typeof raw==="string"?JSON.parse(raw):raw;
const result=evaluateHPA(doc,opts);
fs.writeFileSync("hpa-scaling-report.json",JSON.stringify(result,null,2));
console.log("::"+JSON.stringify({outputs:{status:result.status,reason_count:result.reasons.length,current_replicas:result.current_replicas??-1,max_replicas:result.max_replicas??-1}})+"::");
console.log("HPA scaling check: "+result.status+"; report saved");
- id: "publish_evidence"
type: "io.kestra.plugin.core.output.OutputValues"
description: "Publish the decision and the report URI before any failure."
values:
status: "{{ outputs.check_scaling.vars.status }}"
report_uri: "{{ outputs.check_scaling.outputFiles['hpa-scaling-report.json'] }}"
reason_count: "{{ outputs.check_scaling.vars.reason_count }}"
- id: "scaling_gate"
type: "io.kestra.plugin.core.flow.If"
description: "Fail if autoscaling is blocked, unavailable, or has not observed
the current HPA spec."
condition: "{{ outputs.check_scaling.vars.status == 'BLOCK' }}"
then:
- id: "alert_scaling"
type: "io.kestra.plugin.slack.notifications.SlackIncomingWebhook"
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: "{\"text\":\"HPA BLOCK: {{ inputs.namespace }}/{{ inputs.hpa_name }};
findings={{ outputs.check_scaling.vars.reason_count }}; execution={{
execution.id }}. Read hpa-scaling-report.json.\"}"
- id: "fail_scaling"
type: "io.kestra.plugin.core.execution.Fail"
errorMessage: "HPA status did not pass. Read hpa-scaling-report.json."
else:
- id: "log_scaling"
type: "io.kestra.plugin.core.log.Log"
message: "HPA PASS: {{ inputs.namespace }}/{{ inputs.hpa_name }}. This is a
point-in-time status check."
errors:
- id: "report_probe_error"
type: "io.kestra.plugin.core.log.Log"
message: "HPA probe failed before a PASS result. Execution {{ execution.id }}."