id: dns-record-change-monitor
namespace: company.team
description: |
Resolve a record on a schedule, compare it to the last known value in the KV
store, and alert Slack when DNS changes underneath you.
triggers:
- id: every_thirty_minutes
type: io.kestra.plugin.core.trigger.Schedule
description: Poll often enough to catch unexpected DNS drift or hijacking fast.
cron: "*/30 * * * *"
inputs:
- id: record
type: STRING
defaults: "example.com"
description: DNS name to resolve.
- id: record_type
type: STRING
defaults: "A"
description: Record type to query (A, AAAA, CNAME, MX, TXT).
tasks:
- id: resolve_now
type: io.kestra.plugin.scripts.shell.Commands
description: Resolve the record with dig, sort the answers for a stable
comparison, and emit the joined value via the stdout outputs protocol.
Lookup failure yields an empty value so it cannot masquerade as
"unchanged".
containerImage: alpine:3.20
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
commands:
- |
apk add --no-cache bind-tools python3 >/dev/null 2>&1
cat > resolve.py <<'PYEOF'
import os, json, subprocess
record = os.environ.get("RECORD", "example.com")
rtype = os.environ.get("RTYPE", "A")
value = ""
try:
out = subprocess.run(["dig", "+short", rtype, record], capture_output=True, text=True, timeout=20)
answers = sorted([line.strip() for line in out.stdout.splitlines() if line.strip()])
value = ",".join(answers)
except Exception:
value = ""
print(f"{record} {rtype} -> {value or '(no answer)'}")
print("::" + json.dumps({"outputs": {"value": value}}) + "::")
PYEOF
python3 resolve.py
env:
RECORD: "{{ inputs.record }}"
RTYPE: "{{ inputs.record_type }}"
- id: load_previous
type: io.kestra.plugin.core.kv.Get
description: Read the last known answer, tolerating the first run where nothing
exists yet.
key: "dns-{{ inputs.record }}-{{ inputs.record_type }}"
errorOnMissing: false
- id: changed
type: io.kestra.plugin.core.flow.If
description: Compare old vs new. A missing previous value is first-run and only
seeds the baseline; a differing value is the alert.
condition: "{{ outputs.load_previous.value is not null and
outputs.load_previous.value != outputs.resolve_now.vars.value }}"
then:
- id: alert_change
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post both the old and new answers so the change is unambiguous.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":warning: DNS changed: {{ inputs.record }} {{ inputs.record_type }} moved from '{{ outputs.load_previous.value }}' to '{{ outputs.resolve_now.vars.value }}'. Execution {{ execution.id }}."
}
- id: save_new_value
type: io.kestra.plugin.core.kv.Set
description: Persist the new answer as the baseline for next run.
key: "dns-{{ inputs.record }}-{{ inputs.record_type }}"
kvType: STRING
value: "{{ outputs.resolve_now.vars.value }}"
else:
- id: seed_or_log
type: io.kestra.plugin.core.kv.Set
description: On first run (or unchanged runs) make sure the baseline exists,
then log the state.
key: "dns-{{ inputs.record }}-{{ inputs.record_type }}"
kvType: STRING
value: "{{ outputs.resolve_now.vars.value }}"
errors:
- id: alert_dns_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert when resolution itself breaks - silent DNS trouble is the worst kind.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "DNS monitor FAILED in flow {{ flow.id }} (execution {{ execution.id }}). dig or the worker may be broken."
}
outputs:
- id: current_value
type: STRING
description: 'Current resolved answer, comma-joined, e.g. "93.184.216.34".'
value: "{{ outputs.resolve_now.vars.value }}"