List icon
List icon
Loop icon
Get icon
List icon
Fail icon
Return icon
Log icon
If icon
Pause icon
Get icon
Delete icon
SlackIncomingWebhook icon
Schedule icon

Delete orphan phpIPAM VLANs that no subnet uses

Detect phpIPAM VLANs no subnet is bound to, delete them behind an approval gate then re-read phpIPAM to prove no live subnet was unlinked.

Categories
Infrastructure

Safety

A phpIPAM VLAN delete is not a refusal you can rely on. Vlans::DELETE in phpIPAM 1.7.3 runs remove_object_references("subnets", "vlanId", id) right after the row is removed, so deleting a VLAN that is still in use succeeds and silently sets vlanId to 0 on every subnet bound to it. The IPAM record of which L2 a live range sits on is gone, nothing warns you plus the API returns 200. phpIPAM also accepts duplicate VLAN numbers, so the wrong VLAN is easy to pick. That is why this flow refuses by default and why each guard is load bearing:

Guard What it stops
Binding read unioned over every section from section.List Calling a VLAN unbound because only one section was looked at
section.Get beside every subnet.List in the same iteration Reading an unreachable or unauthorised phpIPAM as an empty one
refuse_incomplete_coverage Acting on a per-section read that silently skipped a section
Declared number window target_number_min to target_number_max Reaching a VLAN outside the pool your team hands out
protected_vlan_numbers plus protected_vlan_ids Deleting a reserved management or infrastructure VLAN that looks idle
grace_days against phpIPAM editDate Deleting a VLAN created for next week's change
AGE_UNKNOWN for a VLAN with no editDate Treating an unknown age as an abandoned VLAN
max_deletes_per_run Emptying the VLAN table when the detector is wrong
auto_remediate false plus dry_run true plus Pause approval Any unattended delete
Fresh eligibility read after the approval plus refuse_stale_plan Deleting a VLAN that someone bound while the approval sat open
vlan.Get plus refuse_identity_drift in front of each delete Deleting whatever now holds a recycled id
verify_subnets_conserved Finishing green after a subnet lost or changed its VLAN binding

How it works

  1. read_sections (io.kestra.plugin.phpipam.ipam.section.List) plus read_vlans_before (io.kestra.plugin.phpipam.ipam.vlan.List) read the two tables the decision needs. The VLAN list carries vlanId, name, number plus editDate.
  2. read_bindings_before (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) walks every section id. In each iteration probe_section_before (io.kestra.plugin.phpipam.ipam.section.Get) proves the base URL, token, TLS plus the section id are good, then read_subnets_before (io.kestra.plugin.phpipam.ipam.subnet.List) reads every subnet in that section, nested children included. The loop declares fetchType: FETCH plus an outputs: list, which is how the per-section results are aggregated back into one binding set. phpIPAM answers 404 No subnets found for a section that holds none, so that read is allowed to fail and counts as zero subnets only because the section read beside it succeeded.
  3. refuse_incomplete_coverage (io.kestra.plugin.core.execution.Fail) stops the run when the aggregated section ids do not match the section list or when a section came back nameless.
  4. select_targets (io.kestra.plugin.core.debug.Return) classifies every VLAN into exactly one status: BOUND, PROTECTED_ID, PROTECTED_NUMBER, NUMBER_UNREADABLE, OUT_OF_WINDOW, AGE_UNKNOWN, WITHIN_GRACE or ORPHAN. BOUND wins over every other reason, so a bound VLAN is never a candidate however old it looks.
  5. log_cleanup_plan (io.kestra.plugin.core.log.Log) prints the counts, the spared VLANs with the reason each was spared plus restore_data: the id, name, number, description, domainId plus editDate of every candidate, which is what ipam.vlan.Create needs to put one back.
  6. refuse_over_ceiling fails the run when the candidate count is above max_deletes_per_run.
  7. remediation_gate (io.kestra.plugin.core.flow.If) requires auto_remediate true, dry_run false plus at least one candidate. confirm_cleanup (io.kestra.plugin.core.flow.Pause) then waits for a human. approved_gate reads onResume.approved ?? false, so a resume that answers nothing is a refusal.
  8. After the approval, reread_sections, reread_vlans plus reread_bindings read phpIPAM again, recheck_targets re-runs the same classification against those fresh reads plus compare_plan_to_present matches the approved rows against the fresh ORPHAN set on id, name plus number. refuse_stale_plan fails the whole batch when any approved VLAN is now bound, protected, renamed, renumbered or gone.
  9. delete_orphans (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) deletes one VLAN at a time. Each iteration re-reads that id with confirm_target_identity (io.kestra.plugin.phpipam.ipam.vlan.Get), refuse_identity_drift stops it on any name or number mismatch, then delete_vlan (io.kestra.plugin.phpipam.ipam.vlan.Delete) writes.
  10. read_vlans_after, read_sections_after plus read_bindings_after run on every path, so even a report-only run produces a before plus after pair. verify_vlan_table, verify_subnets_conserved plus measure_number_ambiguity turn those reads into numbers, log_verification prints them plus fail_if_cleanup_incomplete fails the run when a target survived, something unplanned disappeared or any subnet lost or changed its binding. fail_if_untouched_run_changed_phpipam fails a report-only or declined run whose two reads disagree, because then the report is not evidence.

The outputs worth reading afterwards: {{ outputs.select_targets.value }} is the full classification with one status per VLAN, {{ outputs.compare_plan_to_present.value }} is the post-approval comparison, {{ outputs.verify_vlan_table.value }} holds targets_removed, targets_remaining plus collateral_removed, {{ outputs.verify_subnets_conserved.value }} holds rebound_subnets plus lost_subnets and {{ outputs.measure_number_ambiguity.value }} holds the VLAN numbers more than one record claimed before plus after the run.

What you get

  • The VLAN number pool back. Numbers still claimed by a decommissioned project read as taken, and when an orphan shares a number with a live trunk a planner cannot tell which record is the real one.
  • A delete that is justified in writing: zero bound subnets across every section, an age past the grace window plus a number inside the declared range, with the spared VLANs listed next to the reason each was spared.
  • Restore data in the log before the write, so a wrong delete can be undone with ipam.vlan.Create.
  • A run that fails rather than reporting success when phpIPAM quietly unlinked a live subnet.

Who it's for

Network and platform teams whose phpIPAM is the source of truth for VLAN numbering, where project teardowns leave VLAN records behind plus the next change has to guess which numbers are really free.

Why orchestrate this with Kestra

The manual version is a VLAN list sorted by name, a few /vlans/<id>/subnets/ checks in a browser tab plus a delete button that does not warn you about the subnets it is about to unlink. As a flow the read is complete across sections, the decision is written down with a reason per VLAN, the credentials stay in secrets, the approval is recorded against the execution, the write is serialised at concurrencyLimit: 1 under a flow level concurrency: limit 1 and the second read makes a silent unlink impossible to miss. The Pause task is what makes an irreversible delete safe to automate: detection, report, approval plus verification are one audited unit with a human in the middle.

Tested end to end

Kestra 2.0.5, plugin-phpipam 1.0.8, phpIPAM 1.7.3 (phpipam/phpipam-www:v1.7.3) with MariaDB 11.4.4 behind an nginx 1.27.0 TLS terminator, App token authentication. Every number below was read from phpIPAM's own API, not from a task output: GET /vlans/, GET /sections/<id>/subnets/ plus GET /vlans/search/<number>/.

The fixture holds 11 VLANs across 5 sections, including a bound live trunk that shares number 320 with an abandoned VLAN, a VLAN bound only from a second section, a VLAN bound only by a nested child subnet, a reserved number 42, a VLAN created two days ago, a VLAN with no editDate at all, a section with no subnets plus the stock phpIPAM VLANs.

Case Execution Result
Healthy estate, report only 5rVHR41NxN0Hu6wwU8vfJj 9 VLANs read, 0 candidates, 9 VLANs plus 10 subnets untouched
Two orphans, report only 1jEEkF0Qa0EvCkuCH9Bku7 2 ORPHAN, spared 4 BOUND, 1 PROTECTED_NUMBER, 1 OUT_OF_WINDOW, 2 WITHIN_GRACE, 1 AGE_UNKNOWN, no write
Number window boundary, max 319 1HHpBlCuYgBtUKI7XuS41o VLAN 320 moves to OUT_OF_WINDOW, 1 candidate left
Grace boundary, 421 days 6OwZCRUGizSrQ98KNpRHYb the 420 day VLAN moves to WITHIN_GRACE, 1 candidate left
Protected ids cover both candidates 1MRlTDN0JzAM4QMt8u7wz 0 candidates, 3 protected, no write
Ceiling of 1 with 2 candidates 2y7pyxvWVwiQUZA0suCkZN FAILED at refuse_over_ceiling, 11 VLANs intact
Wrong API token 6qM0d7dVtTs7OaUbdifWRX FAILED on the first read with HTTP 401, nothing read as empty
Approval declined 174oHmiVed7Okw6wumMIjQ write_attempted=no, both candidates still present
Subnet bound during the approval 4HVGnAI9kbwZAw993bsQf6 FAILED at refuse_stale_plan, candidate now BOUND, no delete, subnets 10 to 11 from the fixture change only
Read only token, write refused 6U91HEzbdkeyjIyO5qQacw FAILED, phpIPAM answered HTTP 401 invalid permissions, 11 VLANs intact
Approved cleanup 7GRXeW5V56TEnVxxmptP5U VLANs 11 to 9, subnets 10 to 10 with identical bindings, number 320 ambiguity cleared, GET /vlans/search/320/ 2 rows to 1 (the live trunk id 5), search/311/ 1 row to 0
Second identical run 6pQdGyElMk7sI7FiOV8ajf 0 candidates, Pause never opened, no write
Killed between the two deletes 1Tjy9SIKt61O7UHz8pFaCf KILLED after the first delete, phpIPAM at 10 VLANs with one candidate still there
Next run after the kill 7RsdaPhnVntfMWz344aSAM the queued iteration had settled the batch, 0 candidates, no duplicate delete
Partial pass, window 300 to 315 6s09eygzyRaGESQ5D71SGG deletes only the in-window orphan, VLANs 11 to 10, the other candidate untouched
Reconcile after the partial pass 2ub873QTdSveoPu6Jd4pUN deletes exactly the residual orphan, VLANs 10 to 9, completed work left alone

Both defensive refusals were exercised against the real instance rather than left unproven: a probe flow whose section loop deliberately skipped one section tripped refuse_incomplete_coverage (execution CYJ8X9LVE7IHTpxZrMK41), plus a probe of the per-write identity check passed on a matching name (72AWpoNp4NQwAfqVCvYjaT) and failed on a mismatched one (79WO7aAoDlS51Jq8OjNY5i).

Prerequisites

  • A reachable phpIPAM 1.7.x instance with the API enabled under Administration, API.
  • An API application whose permissions allow read for every section plus write for VLANs. The read only token case above shows what a read only application does: the run fails at the delete instead of reporting success.
  • Minimum permissions: GET /sections/, GET /sections/<id>/, GET /sections/<id>/subnets/, GET /vlans/, GET /vlans/<id>/ plus DELETE /vlans/<id>/. Nothing else is called. Prefer App token authentication: phpIPAM applies per user section permissions to a session token, so a user who cannot see a section would hide the subnet that makes a VLAN ineligible.
  • A Slack incoming webhook for the failure alert.

Secrets

Secret Used for
PHPIPAM_APP_TOKEN Default for the app_token input, sent as the phpIPAM token header
SLACK_WEBHOOK_URL Failure alert in the errors block

Inputs

Input Type Default Purpose
base_url STRING http://localhost phpIPAM root URL
app_id STRING myapp API application id in the /api/{appId}/ path
app_token STRING {{ secret('PHPIPAM_APP_TOKEN') }} App token for that application
target_number_min INT 300 Lowest VLAN number the cleanup may touch
target_number_max INT 399 Highest VLAN number, inclusive
grace_days INT 90 A candidate's editDate must be older than this
protected_vlan_numbers STRING empty Comma separated numbers that are reported, never deleted
protected_vlan_ids STRING empty Comma separated phpIPAM VLAN ids that are never deleted
max_deletes_per_run INT 5 The run refuses above this many candidates
dry_run BOOL true Report only
auto_remediate BOOL false Must be true, with dry_run false, before a delete is offered
insecure_tls BOOL false Trust a self-signed certificate, internal use only

Quick start

  1. Create an API application in phpIPAM under Administration, API with an App code, then store that code as the PHPIPAM_APP_TOKEN secret plus store your Slack webhook as SLACK_WEBHOOK_URL.
  2. Run with base_url, app_id plus the shipped defaults. The run reports and writes nothing.
  3. Read log_cleanup_plan. Check the spared list first: every BOUND, WITHIN_GRACE plus PROTECTED_NUMBER entry is a VLAN the flow refused to touch, which is where a wrong window or grace value shows up.
  4. Set target_number_min plus target_number_max to the pool your team hands out, list your reserved numbers in protected_vlan_numbers plus run the report again.
  5. When the candidate list is right, run with auto_remediate: true plus dry_run: false, read the candidate rows in the Pause, then resume with approved: true. Keep the restore_data line from log_cleanup_plan until you are satisfied.
  6. Read log_verification. removed must list every target, rebound_subnets plus lost_subnets must be empty and ambiguous_numbers should shrink.
  7. Enable weekly_orphan_sweep only after the report has been right for a few weeks. Its inputs pin it to report only.

How to extend

  • Swap the delete for ipam.vlan.Update to park a candidate under a renamed, clearly dated name on the first pass plus delete it on a second pass a month later, which gives the owning team a chance to notice.
  • Add ipam.vrf.List plus treat a VLAN referenced by a VRF route target as BOUND too, if your estate models VRFs in phpIPAM.
  • Read the protected numbers out of Git with io.kestra.plugin.git.Clone instead of an input, so the reserved ranges are reviewed the way code is.
  • Post the plan to the network team's channel on every run, not only on failure, by adding a io.kestra.plugin.slack.notifications.SlackIncomingWebhook task beside log_cleanup_plan.
  • Run one L2 domain per execution by filtering select_targets on domainId, which the VLAN rows already carry.

Limits: a VLAN is judged unbound by subnet bindings only. phpIPAM devices, circuits plus customer links are not read, so a VLAN referenced only by one of those still looks orphaned, which is why the number window plus the protected lists are the real safety boundary. The age test uses phpIPAM's editDate, which any edit refreshes, so a renamed abandoned VLAN looks young; a VLAN with no editDate is never a candidate. phpIPAM has no conditional delete or entity version, so after the fresh post-approval read each delete is preceded by a vlan.Get identity check and the run still fails if the conserved subnet check finds a binding moved. A subnet bound in the seconds between that fresh read and the delete would be unlinked. The flow reports that as a failure with the restore data rather than silently succeeding. phpIPAM accepts duplicate VLAN numbers, so the payoff measured here is number ambiguity cleared, not a create that phpIPAM would have rejected. The empty section handling rests on phpIPAM answering 404 No subnets found only when a section really holds no subnets, which is why section.Get runs beside every subnet read and a credential fault fails the run.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.