id: phpipam-orphan-vlan-cleanup-guard
namespace: company.team
concurrency:
limit: 1
behavior: QUEUE
description: |
Find phpIPAM VLANs that no subnet is bound to, are older than a grace window and sit inside a
declared number range, then delete them behind an approval gate. phpIPAM unlinks subnets from a
VLAN it deletes instead of refusing, so every binding is read across every section first and
phpIPAM is re-read afterwards to prove the cleanup landed and that no live subnet was unlinked.
inputs:
- id: base_url
type: STRING
displayName: phpIPAM base URL
description: Root URL of the phpIPAM instance. Maps to every phpIPAM task's
baseUrl field.
defaults: "http://localhost"
- id: app_id
type: STRING
displayName: Application ID
description: API application identifier configured in phpIPAM under
Administration, API. Maps to every phpIPAM task's appId field.
defaults: "myapp"
- id: app_token
type: STRING
displayName: phpIPAM API token
description: >-
API token for that application. The default reads the PHPIPAM_APP_TOKEN
secret. Kestra renders an input default lazily, so a run that passes a
literal token never touches the secret store. Use an App token rather than
username plus password: phpIPAM applies per user section permissions to a
session token, so a hidden section would hide a binding.
defaults: "{{ secret('PHPIPAM_APP_TOKEN') }}"
- id: target_number_min
type: INT
displayName: Lowest VLAN number the cleanup may touch
description: >-
Start of the declared cleanup range. A VLAN whose number sits outside this
range is reported and never deleted, which keeps the sweep inside the pool
your team hands out.
defaults: 300
- id: target_number_max
type: INT
displayName: Highest VLAN number the cleanup may touch
description: End of the declared cleanup range, inclusive.
defaults: 399
- id: grace_days
type: INT
displayName: Grace window in days
description: >-
A VLAN is only a candidate when its phpIPAM editDate is older than this
many days. A VLAN with no editDate at all is reported as AGE_UNKNOWN and
never deleted, because an unknown age is not evidence of an abandoned
VLAN.
defaults: 90
- id: protected_vlan_numbers
type: STRING
displayName: Protected VLAN numbers
description: Comma separated VLAN numbers that are reported when they look
orphaned but never deleted. Use it for reserved management or
infrastructure numbers.
defaults: ""
- id: protected_vlan_ids
type: STRING
displayName: Protected VLAN IDs
description: Comma separated phpIPAM VLAN IDs that are never deleted, whatever
their number or age.
defaults: ""
- id: max_deletes_per_run
type: INT
displayName: Per-run delete ceiling
description: >-
The run refuses when more VLANs look orphaned than this. A sudden crowd of
candidates usually means a section read is incomplete or a project was
just torn down in bulk, so the flow stops and asks rather than emptying
the VLAN table.
defaults: 5
- id: dry_run
type: BOOL
displayName: Report only
description: True reports the candidates without deleting anything in phpIPAM.
defaults: true
- id: auto_remediate
type: BOOL
displayName: Allow deletes
description: Must be true, with dry_run false, before any phpIPAM delete is even
offered for approval.
defaults: false
- id: insecure_tls
type: BOOL
displayName: Disable TLS certificate validation
description: Set true only for a local or internal instance with a self-signed
certificate.
defaults: false
tasks:
- id: read_sections
type: io.kestra.plugin.phpipam.ipam.section.List
description: Lists every section. A VLAN is bound by a subnet in any section, so
the section list is what makes the binding read complete rather than a
sample.
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
fetchType: FETCH
- id: read_vlans_before
type: io.kestra.plugin.phpipam.ipam.vlan.List
description: Reads every VLAN with its number plus editDate. This is the before
picture the whole run is judged against.
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
fetchType: FETCH
- id: read_bindings_before
type: io.kestra.plugin.core.flow.Loop
description: >-
One subnet read per section, serialised, aggregated back through the
loop's own outputs. phpIPAM answers 404 No subnets found for a section
that holds none, so section.Get runs first in the same iteration: it
proves the URL, the token, TLS plus the section id are good, which is what
lets a failed subnet read count as zero subnets instead of a blind spot.
concurrencyLimit: 1
fetchType: FETCH
values: "{{ outputs.read_sections.sections | toJson | jq('[.[].id | tostring]')
| first }}"
outputs:
- id: section_id
type: STRING
value: "{{ item.value }}"
- id: section_name
type: STRING
value: "{{ outputs.probe_section_before.section.name }}"
- id: subnets_read
type: INT
value: "{{ outputs.read_subnets_before.total ?? -1 }}"
- id: bound_vlan_ids
type: JSON
value: "{{ (outputs.read_subnets_before.subnets ?? []) | toJson | jq('[.[] |
select((.vlanId // \"0\") != \"0\") | (.vlanId | tostring)] | unique')
| first }}"
- id: subnet_bindings
type: JSON
value: "{{ (outputs.read_subnets_before.subnets ?? []) | toJson | jq('[.[] |
{id: (.id | tostring), vlanId: ((.vlanId // \"0\") | tostring)}]') |
first }}"
tasks:
- id: probe_section_before
type: io.kestra.plugin.phpipam.ipam.section.Get
description: Reads the section itself. A credential, TLS or URL fault fails here
and fails the run, so no run can mistake an unreachable phpIPAM for an
empty one.
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
sectionId: "{{ item.value }}"
- id: read_subnets_before
type: io.kestra.plugin.phpipam.ipam.subnet.List
description: Every subnet in this section, nested children included, with the
vlanId each one is bound to.
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
sectionId: "{{ item.value }}"
fetchType: FETCH
allowFailure: true
- id: refuse_incomplete_coverage
type: io.kestra.plugin.core.execution.Fail
description: >-
Refuses when the binding read did not cover every section phpIPAM
returned. It also refuses when a section came back without a name.
Deleting a VLAN on a partial binding read is how a live trunk loses its
subnets.
condition: >-
{{ (outputs.read_sections.sections | toJson | jq('[.[].id | tostring] |
unique | join(",")') | first) != (outputs.read_bindings_before.outputs |
toJson | jq('[.[].outputs.section_id | tostring] | unique | join(",")') |
first) or ((outputs.read_bindings_before.outputs | toJson |
jq('[.[].outputs | select((.section_name // "") == "")] | length') |
first) > 0) }}
errorMessage: >-
Refusing to continue. The per-section binding read does not cover every
section phpIPAM listed, so a VLAN that looks unbound may still carry a
subnet in a section that was not read. Check the API token's read
permission, then retry.
- id: select_targets
type: io.kestra.plugin.core.debug.Return
description: >-
Classifies every VLAN phpIPAM returned into exactly one status. BOUND wins
over every other reason, then PROTECTED_ID, PROTECTED_NUMBER,
NUMBER_UNREADABLE, OUT_OF_WINDOW, AGE_UNKNOWN then WITHIN_GRACE. Only what
survives all of them is an ORPHAN. Each row carries the name, number plus
description needed to recreate the VLAN if it is deleted.
format: >-
{{ {'v': (outputs.read_vlans_before.vlans | toJson), 'b':
(outputs.read_bindings_before.outputs | toJson |
jq('[.[].outputs.bound_vlan_ids[] | tostring] | unique') | first |
toJson), 'pn': inputs.protected_vlan_numbers, 'pi':
inputs.protected_vlan_ids, 'cut': (now(timeZone='UTC') | dateAdd(-1 *
inputs.grace_days, 'DAYS') | date('yyyy-MM-dd HH:mm:ss', timeZone='UTC')),
'lo': inputs.target_number_min, 'hi': inputs.target_number_max} | toJson |
jq(' def trim: sub("^ +";"") | sub(" +$";""); ((.v | fromjson) // []) as
$vl | ((.b | fromjson) // []) as $bound | [ ((.pn // "") | split(","))[] |
trim | select(length > 0) ] as $pnum | [ ((.pi // "") | split(","))[] |
trim | select(length > 0) ] as $pid | (.cut | tostring) as $cut | (.lo) as
$lo | (.hi) as $hi | [ $vl[]
| ((.vlanId // .id) | tostring) as $id
| ((.number // "") | tostring) as $num
| ((.editDate // "") | tostring) as $ed
| (if $num == "" then -1 else ($num | tonumber) end) as $n
| { vlanId: $id, name: ((.name // "") | tostring), number: $num, description: ((.description // "") | tostring), domainId: ((.domainId // "") | tostring), editDate: $ed,
status: (if ($bound | index($id)) != null then "BOUND"
elif ($pid | index($id)) != null then "PROTECTED_ID"
elif ($pnum | index($num)) != null then "PROTECTED_NUMBER"
elif $n < 0 then "NUMBER_UNREADABLE"
elif ($n < $lo or $n > $hi) then "OUT_OF_WINDOW"
elif ($ed == "" or $ed == "null") then "AGE_UNKNOWN"
elif ($ed >= $cut) then "WITHIN_GRACE"
else "ORPHAN" end) } ]
') | first | toJson }}
- id: log_cleanup_plan
type: io.kestra.plugin.core.log.Log
description: >-
The report, plus the restore data. Task outputs are not in the execution
payload, so the candidate rows go to the log where an operator can read
them and, if a delete turns out wrong, recreate the VLAN from name, number
plus description with ipam.vlan.Create.
message: >-
phpIPAM orphan VLAN cleanup plan. vlans_read={{
outputs.read_vlans_before.total }} sections_read={{
outputs.read_sections.total }} subnets_read={{
outputs.read_bindings_before.outputs | toJson |
jq('[.[].outputs.subnets_read | select(. > 0)] | add // 0') | first }}
empty_sections={{ outputs.read_bindings_before.outputs | toJson |
jq('[.[].outputs | select(.subnets_read == -1) | .section_id] |
join(",")') | first }} bound_vlan_ids={{
outputs.read_bindings_before.outputs | toJson |
jq('[.[].outputs.bound_vlan_ids[] | tostring] | unique | join(",")') |
first }} window={{ inputs.target_number_min }}-{{ inputs.target_number_max
}} grace_days={{ inputs.grace_days }} orphans={{
outputs.select_targets.value | jq('[.[] | select(.status == "ORPHAN")] |
length') | first }} bound={{ outputs.select_targets.value | jq('[.[] |
select(.status == "BOUND")] | length') | first }} protected={{
outputs.select_targets.value | jq('[.[] | select(.status == "PROTECTED_ID"
or .status == "PROTECTED_NUMBER")] | length') | first }} out_of_window={{
outputs.select_targets.value | jq('[.[] | select(.status ==
"OUT_OF_WINDOW" or .status == "NUMBER_UNREADABLE")] | length') | first }}
within_grace={{ outputs.select_targets.value | jq('[.[] | select(.status
== "WITHIN_GRACE")] | length') | first }} age_unknown={{
outputs.select_targets.value | jq('[.[] | select(.status ==
"AGE_UNKNOWN")] | length') | first }} restore_data={{
outputs.select_targets.value | jq('[.[] | select(.status == "ORPHAN") |
{vlanId, name, number, description, domainId, editDate}]') | first }}
spared={{ outputs.select_targets.value | jq('[.[] | select(.status !=
"ORPHAN") | {vlanId, number, name, status}]') | first }}
- id: refuse_over_ceiling
type: io.kestra.plugin.core.execution.Fail
description: Refuses once more VLANs look orphaned than the per-run ceiling
allows. The report above is already written, so an operator sees exactly
what tripped it.
condition: >-
{{ (outputs.select_targets.value | jq('[.[] | select(.status == "ORPHAN")]
| length') | first) > inputs.max_deletes_per_run }}
errorMessage: >-
Refusing to delete. The number of orphan VLAN candidates is above
inputs.max_deletes_per_run. Read log_cleanup_plan, confirm the candidates
really are abandoned, then raise the ceiling deliberately.
- id: remediation_gate
type: io.kestra.plugin.core.flow.If
description: The only path that writes. It needs auto_remediate true, dry_run
false plus at least one ORPHAN candidate, which is why the shipped
defaults report then stop.
condition: >-
{{ inputs.auto_remediate and (inputs.dry_run == false) and
((outputs.select_targets.value | jq('[.[] | select(.status == "ORPHAN")] |
length') | first) > 0) }}
then:
- id: confirm_cleanup
type: io.kestra.plugin.core.flow.Pause
description: Holds the run until an operator answers. A VLAN delete is not
reversible through the API and phpIPAM unlinks any subnet still bound
to it, so a human reads the candidate list first.
onResume:
- id: approved
type: BOOL
displayName: Delete these orphan VLANs
description: Set true to delete the VLANs listed in the report. Resuming without
setting it leaves phpIPAM untouched.
defaults: false
- id: approved_gate
type: io.kestra.plugin.core.flow.If
description: A resume that does not answer true leaves phpIPAM alone, so an
accidental resume cannot approve a delete.
condition: "{{ outputs.confirm_cleanup.onResume.approved ?? false }}"
then:
- id: reread_sections
type: io.kestra.plugin.phpipam.ipam.section.List
description: The approval is a snapshot of whatever was true when the Pause
opened. Eligibility is read again here, after the answer, before
any delete.
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
fetchType: FETCH
- id: reread_vlans
type: io.kestra.plugin.phpipam.ipam.vlan.List
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
fetchType: FETCH
- id: reread_bindings
type: io.kestra.plugin.core.flow.Loop
description: A fresh binding read over every section. A subnet bound to a
candidate while the Pause was open shows up here and takes that
candidate out of the plan.
concurrencyLimit: 1
fetchType: FETCH
values: "{{ outputs.reread_sections.sections | toJson | jq('[.[].id |
tostring]') | first }}"
outputs:
- id: section_id
type: STRING
value: "{{ item.value }}"
- id: section_name
type: STRING
value: "{{ outputs.probe_section_fresh.section.name }}"
- id: subnets_read
type: INT
value: "{{ outputs.read_subnets_fresh.total ?? -1 }}"
- id: bound_vlan_ids
type: JSON
value: "{{ (outputs.read_subnets_fresh.subnets ?? []) | toJson | jq('[.[] |
select((.vlanId // \"0\") != \"0\") | (.vlanId | tostring)] |
unique') | first }}"
tasks:
- id: probe_section_fresh
type: io.kestra.plugin.phpipam.ipam.section.Get
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
sectionId: "{{ item.value }}"
- id: read_subnets_fresh
type: io.kestra.plugin.phpipam.ipam.subnet.List
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
sectionId: "{{ item.value }}"
fetchType: FETCH
allowFailure: true
- id: recheck_targets
type: io.kestra.plugin.core.debug.Return
description: Re-runs the same classification against the fresh reads, so the
comparison below is plan against present state rather than plan
against itself.
format: >-
{{ {'v': (outputs.reread_vlans.vlans | toJson), 'b':
(outputs.reread_bindings.outputs | toJson |
jq('[.[].outputs.bound_vlan_ids[] | tostring] | unique') | first |
toJson), 'pn': inputs.protected_vlan_numbers, 'pi':
inputs.protected_vlan_ids, 'cut': (now(timeZone='UTC') |
dateAdd(-1 * inputs.grace_days, 'DAYS') | date('yyyy-MM-dd
HH:mm:ss', timeZone='UTC')), 'lo': inputs.target_number_min, 'hi':
inputs.target_number_max} | toJson | jq(' def trim: sub("^ +";"")
| sub(" +$";""); ((.v | fromjson) // []) as $vl | ((.b | fromjson)
// []) as $bound | [ ((.pn // "") | split(","))[] | trim |
select(length > 0) ] as $pnum | [ ((.pi // "") | split(","))[] |
trim | select(length > 0) ] as $pid | (.cut | tostring) as $cut |
(.lo) as $lo | (.hi) as $hi | [ $vl[]
| ((.vlanId // .id) | tostring) as $id
| ((.number // "") | tostring) as $num
| ((.editDate // "") | tostring) as $ed
| (if $num == "" then -1 else ($num | tonumber) end) as $n
| { vlanId: $id, name: ((.name // "") | tostring), number: $num, description: ((.description // "") | tostring), domainId: ((.domainId // "") | tostring), editDate: $ed,
status: (if ($bound | index($id)) != null then "BOUND"
elif ($pid | index($id)) != null then "PROTECTED_ID"
elif ($pnum | index($num)) != null then "PROTECTED_NUMBER"
elif $n < 0 then "NUMBER_UNREADABLE"
elif ($n < $lo or $n > $hi) then "OUT_OF_WINDOW"
elif ($ed == "" or $ed == "null") then "AGE_UNKNOWN"
elif ($ed >= $cut) then "WITHIN_GRACE"
else "ORPHAN" end) } ]
') | first | toJson }}
- id: compare_plan_to_present
type: io.kestra.plugin.core.debug.Return
description: >-
Matches the approved plan against the fresh ORPHAN set on vlanId
plus name plus number. Anything that moved is listed with the
status it holds now, which is what the refusal below reports.
format: >-
{{ {'p': outputs.select_targets.value, 'f':
outputs.recheck_targets.value} | toJson | jq(' ((.p | fromjson) //
[]) as $plan | ((.f | fromjson) // []) as $fresh | [ $plan[] |
select(.status == "ORPHAN") ] as $approved | [ $approved[] |
.vlanId as $id | (($fresh | map(select(.vlanId == $id)) | first)
// null) as $now |
{ vlanId: $id, name: .name, number: .number,
now_status: (if $now == null then "GONE" else $now.status end),
now_name: (if $now == null then "" else $now.name end),
now_number: (if $now == null then "" else $now.number end),
stale: (if $now == null then true elif $now.status != "ORPHAN" then true elif $now.name != .name then true elif $now.number != .number then true else false end) } ]
') | first | toJson }}
- id: log_fresh_plan
type: io.kestra.plugin.core.log.Log
description: The post-approval picture, so the decision the deletes act on is in
the log next to the one the operator saw.
message: >-
phpIPAM orphan VLAN cleanup re-check after approval. approved={{
outputs.compare_plan_to_present.value | jq('length') | first }}
stale={{ outputs.compare_plan_to_present.value | jq('[.[] |
select(.stale)] | length') | first }} fresh_orphans={{
outputs.recheck_targets.value | jq('[.[] | select(.status ==
"ORPHAN") | .vlanId] | join(",")') | first }} details={{
outputs.compare_plan_to_present.value }}
- id: refuse_stale_plan
type: io.kestra.plugin.core.execution.Fail
description: >-
Refuses the whole batch when an approved VLAN stopped being an
orphan, took a different name or number or vanished. A subnet
bound during the approval lands here. The answer is to stop plus
re-run rather than act on the old list.
condition: >-
{{ (outputs.compare_plan_to_present.value | jq('[.[] |
select(.stale)] | length') | first) > 0 }}
errorMessage: >-
Refusing to delete. At least one approved VLAN changed between the
approval plus the write: it is bound, protected, renamed,
renumbered or already gone. Read log_fresh_plan, then run the flow
again so the approval covers current state.
- id: delete_orphans
type: io.kestra.plugin.core.flow.Loop
description: >-
One phpIPAM delete per orphan VLAN, serialised so two iterations
cannot race on the VLAN table. Each iteration re-reads the VLAN by
its own id first and refuses on any identity drift, so a recycled
id cannot be deleted in place of the approved one.
concurrencyLimit: 1
values: >-
{{ outputs.compare_plan_to_present.value | jq('[.[] | {vlanId,
name, number} | tojson]') | first }}
tasks:
- id: confirm_target_identity
type: io.kestra.plugin.phpipam.ipam.vlan.Get
description: Reads this one VLAN by id immediately before the delete. phpIPAM
answers 404 Vlan not found if it went away, which fails this
iteration instead of deleting whatever now holds the id.
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
vlanId: "{{ item.value | jq('.vlanId') | first }}"
- id: refuse_identity_drift
type: io.kestra.plugin.core.execution.Fail
description: Stops this iteration when the id no longer carries the approved
name plus number.
condition: >-
{{ (outputs.confirm_target_identity.vlan.name ?? '') !=
(item.value | jq('.name') | first) or
((outputs.confirm_target_identity.vlan.number ?? '') | string)
!= ((item.value | jq('.number') | first) | string) }}
errorMessage: >-
Refusing to delete this VLAN. The id no longer carries the
name plus number that were approved, so phpIPAM is holding
something else under that id.
- id: delete_vlan
type: io.kestra.plugin.phpipam.ipam.vlan.Delete
description: The write. phpIPAM deletes the VLAN and unlinks any subnet still
pointing at it, which is why the fresh binding read above has
to come back empty for this id first.
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
vlanId: "{{ item.value | jq('.vlanId') | first }}"
- id: record_write_attempt
type: io.kestra.plugin.core.debug.Return
description: Marks that the approved delete path ran. The verification below
only fails the run for leftovers when this exists, so a
report-only run is never failed for candidates it deliberately
left alone.
format: applied
- id: read_vlans_after
type: io.kestra.plugin.phpipam.ipam.vlan.List
description: Reads the VLAN table again from phpIPAM. It runs on every path, so
a report-only or declined run produces a before plus after pair that has
to be identical.
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
fetchType: FETCH
- id: read_sections_after
type: io.kestra.plugin.phpipam.ipam.section.List
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
fetchType: FETCH
- id: read_bindings_after
type: io.kestra.plugin.core.flow.Loop
description: The subnets again, section by section. This is what proves no live
subnet lost its VLAN binding while the orphans were removed.
concurrencyLimit: 1
fetchType: FETCH
values: "{{ outputs.read_sections_after.sections | toJson | jq('[.[].id |
tostring]') | first }}"
outputs:
- id: section_id
type: STRING
value: "{{ item.value }}"
- id: subnets_read
type: INT
value: "{{ outputs.read_subnets_after.total ?? -1 }}"
- id: subnet_bindings
type: JSON
value: "{{ (outputs.read_subnets_after.subnets ?? []) | toJson | jq('[.[] | {id:
(.id | tostring), vlanId: ((.vlanId // \"0\") | tostring)}]') | first
}}"
tasks:
- id: probe_section_after
type: io.kestra.plugin.phpipam.ipam.section.Get
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
sectionId: "{{ item.value }}"
- id: read_subnets_after
type: io.kestra.plugin.phpipam.ipam.subnet.List
baseUrl: "{{ inputs.base_url }}"
appId: "{{ inputs.app_id }}"
auth:
appToken: "{{ inputs.app_token }}"
insecureTls: "{{ inputs.insecure_tls }}"
sectionId: "{{ item.value }}"
fetchType: FETCH
allowFailure: true
- id: verify_vlan_table
type: io.kestra.plugin.core.debug.Return
description: >-
Compares the VLAN table before against after. It reports which planned
VLANs are gone, which are still there plus anything that disappeared
without being planned, which is the collateral check the failure below
acts on.
format: >-
{{ {'p': outputs.select_targets.value, 'vb':
(outputs.read_vlans_before.vlans | toJson), 'va':
(outputs.read_vlans_after.vlans | toJson)} | toJson | jq(' ((.p |
fromjson) // []) as $plan | ((.vb | fromjson) // []) as $vb | ((.va |
fromjson) // []) as $va | [ $plan[] | select(.status == "ORPHAN") |
.vlanId ] as $t | [ $vb[] | (.vlanId | tostring) ] as $bi | [ $va[] |
(.vlanId | tostring) ] as $ai | { targets: $t,
targets_removed: [ $t[] | . as $x | select(($ai | index($x)) == null) ],
targets_remaining: [ $t[] | . as $x | select(($ai | index($x)) != null) ],
collateral_removed: [ $bi[] | . as $x | select(($ai | index($x)) == null) | select(($t | index($x)) == null) ],
vlans_before: ($bi | length), vlans_after: ($ai | length) }
') | first | toJson }}
- id: verify_subnets_conserved
type: io.kestra.plugin.core.debug.Return
description: >-
The conserved data check. phpIPAM unlinks subnets from a VLAN it deletes,
so every subnet read before has to still exist afterwards and still point
at the same VLAN. A single rebound or missing subnet is a failure, not a
rounding error.
format: >-
{{ {'sb': (outputs.read_bindings_before.outputs | toJson |
jq('[.[].outputs.subnet_bindings[]]') | first | toJson), 'sa':
(outputs.read_bindings_after.outputs | toJson |
jq('[.[].outputs.subnet_bindings[]]') | first | toJson)} | toJson | jq('
((.sb | fromjson) // []) as $sb | ((.sa | fromjson) // []) as $sa | [
$sa[] | .id ] as $sa_ids | { subnets_before: ($sb | length),
subnets_after: ($sa | length),
lost_subnets: [ $sb[] | .id | . as $x | select(($sa_ids | index($x)) == null) ],
rebound_subnets: [ $sb[] | . as $old | (($sa | map(select(.id == $old.id)) | first) // null) as $new | select($new != null) | select($new.vlanId != $old.vlanId) | {id: $old.id, from: $old.vlanId, to: $new.vlanId} ] }
') | first | toJson }}
- id: measure_number_ambiguity
type: io.kestra.plugin.core.debug.Return
description: >-
The operational payoff, measured rather than asserted: VLAN numbers inside
the declared window that more than one phpIPAM VLAN record claims. phpIPAM
accepts duplicate numbers happily, so every abandoned VLAN leaves a number
that a planner cannot resolve.
format: >-
{{ {'vb': (outputs.read_vlans_before.vlans | toJson), 'va':
(outputs.read_vlans_after.vlans | toJson), 'lo': inputs.target_number_min,
'hi': inputs.target_number_max} | toJson | jq(' ((.vb | fromjson) // [])
as $vb | ((.va | fromjson) // []) as $va | (.lo) as $lo | (.hi) as $hi |
([ $vb[] | ((.number // "") | tostring) | select(. != "") | select((. |
tonumber) >= $lo and (. | tonumber) <= $hi) ] | group_by(.) | [ .[] |
select(length > 1) | .[0] ]) as $ab | ([ $va[] | ((.number // "") |
tostring) | select(. != "") | select((. | tonumber) >= $lo and (. |
tonumber) <= $hi) ] | group_by(.) | [ .[] | select(length > 1) | .[0] ])
as $aa | ([ $vb[] | ((.number // "") | tostring) | select(. != "") |
select((. | tonumber) >= $lo and (. | tonumber) <= $hi) ] | unique |
length) as $ub | ([ $va[] | ((.number // "") | tostring) | select(. != "")
| select((. | tonumber) >= $lo and (. | tonumber) <= $hi) ] | unique |
length) as $ua | { ambiguous_numbers_before: $ab, ambiguous_numbers_after:
$aa,
occupied_numbers_before: $ub, occupied_numbers_after: $ua }
') | first | toJson }}
- id: log_verification
type: io.kestra.plugin.core.log.Log
description: The after picture, read back from phpIPAM rather than assumed from
the delete tasks going green, with the VLAN number ambiguity the cleanup
was supposed to clear.
message: >-
phpIPAM orphan VLAN cleanup verification. write_attempted={{
outputs.record_write_attempt is defined ? 'yes' : 'no' }} targets={{
outputs.verify_vlan_table.value | jq('.targets | join(",")') | first }}
removed={{ outputs.verify_vlan_table.value | jq('.targets_removed |
join(",")') | first }} remaining={{ outputs.verify_vlan_table.value |
jq('.targets_remaining | join(",")') | first }} collateral_removed={{
outputs.verify_vlan_table.value | jq('.collateral_removed | join(",")') |
first }} vlans={{ outputs.verify_vlan_table.value | jq('.vlans_before') |
first }}->{{ outputs.verify_vlan_table.value | jq('.vlans_after') | first
}} subnets={{ outputs.verify_subnets_conserved.value |
jq('.subnets_before') | first }}->{{
outputs.verify_subnets_conserved.value | jq('.subnets_after') | first }}
rebound_subnets={{ outputs.verify_subnets_conserved.value |
jq('.rebound_subnets') | first }} lost_subnets={{
outputs.verify_subnets_conserved.value | jq('.lost_subnets | join(",")') |
first }} ambiguous_numbers={{ outputs.measure_number_ambiguity.value |
jq('.ambiguous_numbers_before | join(",")') | first }}->{{
outputs.measure_number_ambiguity.value | jq('.ambiguous_numbers_after |
join(",")') | first }} occupied_numbers_in_window={{
outputs.measure_number_ambiguity.value | jq('.occupied_numbers_before') |
first }}->{{ outputs.measure_number_ambiguity.value |
jq('.occupied_numbers_after') | first }}
- id: fail_if_cleanup_incomplete
type: io.kestra.plugin.core.execution.Fail
description: >-
Fails the run when a delete was approved yet a target is still there, when
something that was not a target disappeared or when any subnet lost or
changed its VLAN binding. phpIPAM answers 200 to a delete it did not
apply, so this second read is the only thing that can tell the two apart.
condition: >-
{{ (outputs.record_write_attempt is defined) and
((outputs.verify_vlan_table.value | jq('.targets_remaining | length') |
first) > 0 or (outputs.verify_vlan_table.value | jq('.collateral_removed |
length') | first) > 0 or (outputs.verify_subnets_conserved.value |
jq('.rebound_subnets | length') | first) > 0 or
(outputs.verify_subnets_conserved.value | jq('.lost_subnets | length') |
first) > 0) }}
errorMessage: >-
The cleanup did not land cleanly. Either an approved VLAN is still
present, a VLAN that was not a target is gone or a subnet lost or changed
its VLAN binding. Read log_verification, then check the API token's write
permission plus the restore data in log_cleanup_plan.
- id: fail_if_untouched_run_changed_phpipam
type: io.kestra.plugin.core.execution.Fail
description: >-
Fails a report-only or declined run that nevertheless sees VLANs or
subnets change between its two reads. Nothing in this flow wrote, so that
is either someone else working in phpIPAM or a read that cannot be
trusted. Either way the report is not evidence.
condition: >-
{{ (outputs.record_write_attempt is not defined) and
((outputs.verify_vlan_table.value | jq('.vlans_before') | first) !=
(outputs.verify_vlan_table.value | jq('.vlans_after') | first) or
(outputs.verify_subnets_conserved.value | jq('.subnets_before') | first)
!= (outputs.verify_subnets_conserved.value | jq('.subnets_after') | first)
or (outputs.verify_subnets_conserved.value | jq('.rebound_subnets |
length') | first) > 0) }}
errorMessage: >-
This run did not write to phpIPAM, yet the VLAN or subnet set changed
between its two reads. Someone or something else is changing phpIPAM right
now. Read log_verification and re-run the report before acting on it.
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: >-
Posts the failure to Slack. The refusals above arrive here too, because a
refused cleanup is something an operator has to see.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: >-
{{ {"text": "phpIPAM orphan VLAN cleanup guard failed. Execution " ~
execution.id ~ ". Read log_cleanup_plan plus log_verification before
retrying; the candidate rows in log_cleanup_plan carry the data to
recreate a VLAN."} | toJson }}
triggers:
- id: weekly_orphan_sweep
type: io.kestra.plugin.core.trigger.Schedule
description: Disabled on purpose. Set the number window plus the protected
numbers for your estate first, then enable it. Its inputs pin the run to
report only.
disabled: true
cron: "0 6 * * 1"
inputs:
dry_run: true
auto_remediate: false