id: domain-expiry-renewal-guard
namespace: company.team
description: |
Sweep the registration expiry dates of every domain in your portfolio with whois, score days remaining against warn and critical thresholds, and escalate anything close to lapsed as a GitHub renewal issue plus a Slack alert.
triggers:
- id: weekly_expiry_sweep
type: io.kestra.plugin.core.trigger.Schedule
description: Weekly Monday sweep over the whole domain list. Shipped disabled,
flip it to false once the thresholds and the target repository look right.
cron: "0 6 * * 1"
disabled: true
inputs:
- id: domains
type: STRING
displayName: Domains to check
description: Comma separated list of domain registrations that whois should look up.
defaults: example.com,example.org
- id: warn_days
type: INT
displayName: Warn threshold in days
description: Any domain expiring sooner than this many days lands in the warn bucket.
defaults: 90
- id: critical_days
type: INT
displayName: Critical threshold in days
description: Any domain expiring sooner than this many days lands in the
critical bucket.
defaults: 30
tasks:
# --- 1. collect the raw whois text ---
- id: lookup_registrations
type: io.kestra.plugin.scripts.shell.Commands
description: Install the whois client in an Alpine container and print one
marker delimited block per domain so every registration is captured as raw
text.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: alpine:3.20
commands:
- apk add --no-cache whois >/dev/null
- |
set -e
for domain in $(echo "{{ inputs.domains }}" | tr ',' ' '); do
echo "### DOMAIN ${domain}"
if ! whois "${domain}"; then
echo "whois lookup did not complete for ${domain}"
fi
echo ""
done
# --- 2. score every domain ---
- id: assess_expiry
type: io.kestra.plugin.scripts.python.Script
description: Pull the expiry date out of each whois block, compute days
remaining, map every domain to critical, warn, ok, or unknown, and emit
the verdict payload.
taskRunner:
type: io.kestra.plugin.core.runner.Process
env:
DOMAINS: "{{ inputs.domains }}"
WHOIS_RAW: "{{ outputs.lookup_registrations.stdOut }}"
WARN_DAYS: "{{ inputs.warn_days }}"
CRITICAL_DAYS: "{{ inputs.critical_days }}"
TODAY: "{{ now() | date('yyyy-MM-dd') }}"
script: |
import json
import os
import re
from datetime import date
EXPIRY_HINTS = ("expir", "paid-till", "paid till", "renewal date", "renews on")
MARKER = "### DOMAIN "
MONTHS = {"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}
def env_int(name, default):
raw = os.environ.get(name, "")
try:
return int(str(raw).strip())
except (TypeError, ValueError):
return default
def parse_date(value):
text = str(value or "").strip()
if not text:
return None
match = re.search(r"(\d{4})[-./](\d{1,2})[-./](\d{1,2})", text)
if match:
try:
return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
except ValueError:
return None
match = re.search(r"(\d{1,2})[-\s]([A-Za-z]{3})[a-z]*[-\s]+(\d{4})", text)
if match:
month = MONTHS.get(match.group(2).lower())
if month:
try:
return date(int(match.group(3)), month, int(match.group(1)))
except ValueError:
return None
match = re.search(r"([A-Za-z]{3})[a-z]*\s+(\d{1,2}),?\s+(\d{4})", text)
if match:
month = MONTHS.get(match.group(1).lower())
if month:
try:
return date(int(match.group(3)), month, int(match.group(2)))
except ValueError:
return None
return None
def extract_expiry(text):
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("%") or stripped.startswith(">>>"):
continue
if ":" not in stripped:
continue
key, _, value = stripped.partition(":")
key_norm = " ".join(key.lower().split())
if not any(hint in key_norm for hint in EXPIRY_HINTS):
continue
found = parse_date(value)
if found:
return found
return None
def split_sections(raw):
sections = {}
current = None
for line in str(raw or "").splitlines():
if line.startswith(MARKER):
current = line[len(MARKER):].strip().lower()
sections.setdefault(current, "")
elif current:
sections[current] += line + "\n"
return sections
sections = split_sections(os.environ.get("WHOIS_RAW", ""))
critical_after = env_int("CRITICAL_DAYS", 30)
warn_after = env_int("WARN_DAYS", 90)
today = parse_date(os.environ.get("TODAY", "")) or date.today()
names = []
for part in str(os.environ.get("DOMAINS", "")).split(","):
name = part.strip().lower().rstrip(".")
if name and name not in names:
names.append(name)
records = []
for name in names:
expiry = extract_expiry(sections.get(name, ""))
if expiry is None:
records.append({
"domain": name,
"days_remaining": None,
"expiry_date": "",
"severity": "unknown",
"detail": "no expiry date found in the whois text",
})
continue
days = (expiry - today).days
if days < critical_after:
severity = "critical"
elif days < warn_after:
severity = "warn"
else:
severity = "ok"
records.append({
"domain": name,
"days_remaining": days,
"expiry_date": expiry.isoformat(),
"severity": severity,
"detail": "",
})
criticals = sorted([r for r in records if r["severity"] == "critical"],
key=lambda r: r["days_remaining"])
warns = sorted([r for r in records if r["severity"] == "warn"],
key=lambda r: r["days_remaining"])
unknowns = [r for r in records if r["severity"] == "unknown"]
oks = sorted([r for r in records if r["severity"] == "ok"],
key=lambda r: r["days_remaining"])
if criticals:
verdict = "critical"
elif warns or unknowns:
verdict = "warn"
else:
verdict = "ok"
dated = [r for r in records if r["days_remaining"] is not None]
if dated:
nearest = min(dated, key=lambda r: r["days_remaining"])
nearest_expiry = {
"domain": nearest["domain"],
"days_remaining": nearest["days_remaining"],
"expiry_date": nearest["expiry_date"],
}
else:
nearest_expiry = {"domain": "", "days_remaining": None, "expiry_date": ""}
rank = {"critical": 0, "warn": 1, "unknown": 2, "ok": 3}
ranked = sorted(
records,
key=lambda r: (
rank.get(r["severity"], 4),
r["days_remaining"] is None,
r["days_remaining"] if r["days_remaining"] is not None else 0,
r["domain"],
),
)
urgency_lines = []
for rec in ranked:
if rec["days_remaining"] is None:
urgency_lines.append(rec["domain"] + ": no expiry date parsed (" + rec["severity"] + ")")
else:
urgency_lines.append(
rec["domain"] + ": " + str(rec["days_remaining"]) + " days left, expires "
+ rec["expiry_date"] + " (" + rec["severity"] + ")"
)
summary = (
str(len(criticals)) + " critical, " + str(len(warns)) + " warn, "
+ str(len(unknowns)) + " unknown, " + str(len(oks)) + " ok of "
+ str(len(records)) + " domains"
)
if nearest_expiry["domain"]:
summary += (
", nearest expiry " + nearest_expiry["domain"] + " in "
+ str(nearest_expiry["days_remaining"]) + " days on " + nearest_expiry["expiry_date"]
)
summary = summary.replace('"', "'").replace("\n", " ")
payload = {"outputs": {
"verdict": verdict,
"checked": len(records),
"critical_count": len(criticals),
"warn_count": len(warns),
"unknown_count": len(unknowns),
"ok_count": len(oks),
"critical_domains": criticals,
"warn_domains": warns,
"unknown_domains": unknowns,
"ok_domains": oks,
"nearest_expiry": nearest_expiry,
"urgency_table": "\n".join(urgency_lines),
"urgency_inline": " | ".join(urgency_lines),
"summary": summary,
}}
print("verdict=" + verdict + " checked=" + str(len(records))
+ " critical=" + str(len(criticals)) + " warn=" + str(len(warns))
+ " unknown=" + str(len(unknowns)))
print("::" + json.dumps(payload) + "::")
# --- 3. escalate or stay quiet ---
- id: route_verdict
type: io.kestra.plugin.core.flow.If
description: Branch on the verdict so critical and warn domains open a renewal
ticket and alert Slack while a clean sweep only records itself.
condition: "{{ outputs.assess_expiry.vars.verdict != 'ok' }}"
then:
- id: open_renewal_issue
type: io.kestra.plugin.github.issues.Create
description: File a renewal ticket whose body lists every checked domain sorted
by urgency, so the nearest expiry is the first line an owner reads.
jwtToken: "{{ secret('GITHUB_TOKEN') }}"
repository: your-org/ops-backlog
title: "Domain renewal queue - {{ outputs.assess_expiry.vars.critical_count }}
critical, {{ outputs.assess_expiry.vars.warn_count }} warn, {{
outputs.assess_expiry.vars.unknown_count }} unknown"
body: |
## Domain renewal queue
{{ outputs.assess_expiry.vars.urgency_table }}
{{ outputs.assess_expiry.vars.summary }}
Thresholds, critical below {{ inputs.critical_days }} days and warn below {{ inputs.warn_days }} days, came from the flow inputs of execution {{ execution.id }}.
labels:
- domain-renewal
- expiry
- id: alert_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the verdict and the urgency order to the ops channel so nobody
has to open the ticket to see which domain is closest.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "Domain expiry sweep {{ outputs.assess_expiry.vars.verdict }} for {{ inputs.domains }}. {{ outputs.assess_expiry.vars.summary }}. Urgency order, {{ outputs.assess_expiry.vars.urgency_inline }}. A renewal issue is open in the backlog. Execution {{ execution.id }}."
}
else:
- id: all_clear_note
type: io.kestra.plugin.core.log.Log
description: Record the green sweep in the execution history so there is dated
proof the check ran.
message: "Domain expiry sweep clear. {{ outputs.assess_expiry.vars.summary }}."
errors:
- id: slack_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Report a failed sweep, because a container that never started looks
exactly like a healthy portfolio until a domain lapses.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "Domain expiry renewal guard FAILED in {{ flow.namespace }}.{{ flow.id }}, execution {{ execution.id }}. No verdict was produced, so registration expiry was NOT verified."
}
outputs:
- id: verdict
type: STRING
description: Overall severity of the sweep, one of critical, warn, ok.
value: "{{ outputs.assess_expiry.vars.verdict }}"
- id: critical_domains
type: JSON
description: Domains inside the critical window with days remaining and expiry
date, nearest first.
value: "{{ outputs.assess_expiry.vars.critical_domains | toJson }}"
- id: warn_domains
type: JSON
description: Domains inside the warn window with days remaining and expiry date,
nearest first.
value: "{{ outputs.assess_expiry.vars.warn_domains | toJson }}"
- id: nearest_expiry
type: JSON
description: The domain that expires soonest, with its days remaining and expiry date.
value: "{{ outputs.assess_expiry.vars.nearest_expiry | toJson }}"