Design specification
This is the original design document the project was built from, kept as the record of why the architecture looks the way it does. For installation and usage, start at the README. Where the two disagree, the README describes what actually ships: notably the API group is
reactor.robbeverhelst.com, and UPS state arrived as part of the UniFi provider rather than as a separate NUT provider, because UniFi consoles already report it.
Event-driven automation for UniFi networks and Kubernetes.
UniFi Reactor is an open-source Kubernetes Operator that allows UniFi events to trigger configurable actions in Kubernetes, external APIs, and other infrastructure.
The initial focus is UniFi-first: make it extremely easy for a homelab operator to react to events from a UniFi environment.
The architecture should be designed so additional event providers and action providers can be added later without rewriting the core automation engine.
The problem
Section titled “The problem”UniFi can detect important infrastructure events:
- WAN outages
- WAN failover
- WAN restoration
- UniFi device failures
- clients connecting/disconnecting
- network events
- potentially UniFi Protect events in the future
But detecting an event is only half the problem.
A homelab often needs to react to those events.
For example:
Telenet WAN fails ↓UDM switches to U5G backup ↓UniFi emits failover event ↓Reactor receives event ↓qBittorrent pauseslarge downloads stopbackup jobs stopbandwidth-heavy workloads scale down ↓Telenet returns ↓Reactor receives recovery event ↓normal workloads resumeToday this can be assembled using combinations of UniFi webhooks, Argo Events, n8n, Home Assistant, custom scripts, Kubernetes APIs, etc.
Reactor should make common infrastructure automations much simpler.
Design risks and open questions
Section titled “Design risks and open questions”Read this before implementing anything.
Risk 1: the webhook assumption
Section titled “Risk 1: the webhook assumption”The entire UniFi provider design depends on what UniFi’s Alarm Manager / webhook mechanism actually emits. UniFi webhooks are known to be limited in event coverage and payload detail, and it is not guaranteed that a clean, machine-usable WAN failover webhook exists at all.
Before writing any operator code, perform this experiment:
- Configure a webhook receiver (a simple request-logging endpoint is enough).
- Point UniFi’s webhook/Alarm Manager configuration at it.
- Physically trigger a real WAN failover (pull the primary uplink) and a recovery.
- Capture exactly what UniFi sends, when, and with what payload.
- Store the captured payloads in
testdata/as the ground truth for the parser.
If UniFi does not emit a usable failover webhook, the provider design shifts to polling the UniFi Network API for WAN state (see the state-first design below), and webhooks become an optional fast path rather than the foundation.
Risk 2: events are fragile, state is not
Section titled “Risk 2: events are fragile, state is not”A missed webhook delivery (controller restart, network blip, UniFi not retrying) must not leave the cluster stuck in the wrong mode. This is why the design below treats observed UniFi state as the source of truth and webhooks as an optimization, not as the mechanism of record.
Primary goals
Section titled “Primary goals”- Provide excellent UniFi integration.
- Make UniFi events usable inside Kubernetes.
- Provide declarative automation through Kubernetes CRDs.
- Make common actions extremely easy to configure.
- Provide reliable event processing.
- Make automations observable.
- Make the system safe by default.
- Keep the core architecture provider-agnostic.
- Make adding future providers straightforward.
- Remain lightweight enough for a homelab.
Non-goals
Section titled “Non-goals”Reactor is not intended to become:
- a replacement for UniFi Network
- a replacement for UniFi OS
- a general-purpose workflow platform like n8n
- a replacement for Argo Workflows
- a replacement for Home Assistant
- an arbitrary remote shell execution platform
- a huge enterprise workflow engine
The goal is focused infrastructure automation.
Product philosophy
Section titled “Product philosophy”The core concept is:
EVENT / STATE CHANGE ↓MATCH / CONDITION ↓ACTIONExample:
UniFi WAN failover ↓WAN = backup ↓pause qBittorrentThere is an important distinction inside this model:
- Some things are states:
wan = primary | backup,device = online | offline. They have a current value that can be observed at any time, and automations should reconcile against them. - Some things are genuinely events:
client.connected, a Protect motion detection. They happen at a moment in time and cannot be re-observed later.
Reactor supports both, but state is the primary abstraction for anything that has one. This is what makes the system self-healing: if a notification is missed, the next observation of state corrects the world.
The core engine should not know what UniFi is.
UniFi is a provider that converts UniFi-specific events and observed state into normalized Reactor events and state.
This distinction is important.
Initial product
Section titled “Initial product”The first release should be UniFi Reactor, not a giant generic automation framework.
Initial provider:
UniFiInitial action targets:
KubernetesHTTP/WebhooksNotificationsThe architecture should make future providers possible:
UniFiNUTProxmoxPrometheusHome AssistantWebhook...But these should not all be implemented before the first usable release.
Architecture
Section titled “Architecture”High-level architecture:
UniFi │ ┌────────────┴────────────┐ │ webhook (fast path) │ API poll (source of truth) ▼ ▼ ┌───────────────────────────────┐ │ UniFi Provider │ │ parse / observe / normalize │ └──────────────┬────────────────┘ │ normalized Event / State │ ▼ ┌─────────────────┐ │ Reactor Engine │ │ │ │ Matching │ │ Conditions │ │ State │ │ Execution │ └────────┬────────┘ │ normalized Action │ ┌─────────────┼──────────────┐ ▼ ▼ ▼Kubernetes HTTP Notification Provider Provider ProviderThe UniFi provider maintains an observed-state model (initially just WAN state) by polling the UniFi Network API on a short interval. An incoming webhook does not drive actions directly; it triggers an immediate re-observation of state. This gives low latency when webhooks work and correctness when they do not.
Kubernetes architecture
Section titled “Kubernetes architecture”Reactor should be implemented as a proper Kubernetes Operator.
The controller should watch Reactor custom resources.
Initial CRDs:
AutomationPotential future CRDs:
EventSourceActionProviderAutomationDo not introduce unnecessary CRDs during the first implementation.
Start with a single Automation CRD and keep the provider configuration simple.
The Automation API: two trigger kinds from day one
Section titled “The Automation API: two trigger kinds from day one”The v1alpha1 API distinguishes state triggers from event triggers from the start, because migrating users from an event-shaped API to a state-shaped API later would be a breaking change for the flagship use case.
Decided, and revised: the split is kept —
whenis state-shaped and always will be — butspec.triggeris not in thev1alpha1schema. It shipped through v0.3.0 as a field the engine never processed, which is not a defensible thing to carry into v1. It returns inv1alpha2once (a) a real Alarm Manager delivery has been captured intestdata/, so a payload matcher is written against observed ground truth rather than an assumed shape, and (b) at least one edge action exists for it to run — every action type today declares a level and is arbitrated continuously across the automations sharing a target, which is not something an occurrence can contribute to. The argument in this section is aboutwhennever having to migrate, and that is satisfied withouttriggerbeing present but inert. See the README’s Stability section.
State trigger (preferred for anything that has a state)
Section titled “State trigger (preferred for anything that has a state)”apiVersion: reactor.robbeverhelst.dev/v1alpha1kind: Automationmetadata: name: degrade-on-backup-wanspec: when: provider: unifi state: wan: backup
actions: - type: kubernetes.scale target: kind: Deployment name: qbittorrent replicas: 0
onExit: - type: kubernetes.scale target: kind: Deployment name: qbittorrent replicas: 1A state trigger fires its actions when the state enters the condition and its onExit actions when the state leaves it. This replaces the pattern of manually authoring inverse automation pairs. onExit is optional; automatic reversal is explicit, never inferred.
Event trigger (for genuine point-in-time events)
Section titled “Event trigger (for genuine point-in-time events)”apiVersion: reactor.robbeverhelst.dev/v1alpha1kind: Automationmetadata: name: notify-on-client-connectspec: trigger: provider: unifi event: client.connected
actions: - type: http.request request: method: POST url: https://example.internal/hookAn Automation specifies exactly one of when (state) or trigger (event). Exact API naming can be refined during implementation, but the two-kind split itself should be kept.
As shipped,
v1alpha1accepts onlywhen; the example above is the shapetriggerwill have when it returns, not a schema that exists today.
Events and state
Section titled “Events and state”Reactor should normalize provider-specific events.
A normalized event should contain at minimum:
idprovidertypetimestamppayloadmetadataConceptually:
{ "id": "01J...", "provider": "unifi", "type": "wan.failover", "timestamp": "2026-08-11T10:00:00Z", "payload": {}, "metadata": {}}Provider-specific data should remain available through payload.
Normalized state is a flat, provider-scoped key/value model:
{ "provider": "unifi", "state": { "wan": "backup" }, "observedAt": "2026-08-11T10:00:00Z"}State changes are derived by the provider comparing observations, and the engine reconciles Automations against current state. Missed intermediate transitions are acceptable by design; the current observation always wins.
UniFi provider
Section titled “UniFi provider”UniFi is the first-class integration.
The provider has two inputs:
1. State polling (source of truth)
Section titled “1. State polling (source of truth)”The provider polls the UniFi Network API for WAN state on a configurable interval (default in the range of 15 to 60 seconds). This is what actually drives state-based Automations. Polling makes the system:
- self-healing after controller restarts
- immune to missed webhook deliveries
- naturally deduplicated (state is compared, not counted)
2. Webhooks (fast path)
Section titled “2. Webhooks (fast path)”The provider exposes a local HTTP endpoint for UniFi to send events to.
POST /webhooks/unifiThe first implementation should use UniFi’s supported webhook/event mechanisms rather than relying on undocumented internal APIs unless absolutely necessary.
For state-shaped events (like WAN failover), a webhook does not directly execute automations. It triggers an immediate out-of-band poll so state converges in seconds instead of waiting for the next interval. For genuine events (client connects etc.), the webhook payload is normalized into an event as-is.
The endpoint must:
- authenticate the webhook
- validate the payload
- assign/generate an event ID if necessary
- normalize the event or schedule a state re-observation
- enqueue/process the event
- return quickly to UniFi
- avoid performing long-running actions synchronously inside the webhook handler
Do not make UniFi wait for an automation to complete.
UniFi event mapping
Section titled “UniFi event mapping”The initial taxonomy should be small and useful.
State keys:
wan: primary | backupPotential events:
wan.failover (also mapped to state)wan.restored (also mapped to state)device.online (candidate for state in a later release)device.offlineclient.connectedclient.disconnectedThe implementation must be based on the actual captured UniFi payloads from the experiment described in “Design risks” above, and on the actual UniFi Network API responses for WAN state.
Do not invent payload formats.
Create a mapping layer:
UniFi payload / API response ↓UniFi parser / observer ↓Reactor normalized event / stateKeep the parser isolated from the automation engine.
Event matching
Section titled “Event matching”Automations need to be able to match events.
Simple:
trigger: provider: unifi event: client.connectedMore specific:
trigger: provider: unifi event: device.offline match: deviceId: "..."Do not initially build an unnecessarily complex expression language.
Start with:
- provider
- event type or state key/value
- simple field matching
A future condition engine can support:
conditions: all: - ... - ...but this should only be implemented when necessary.
Actions
Section titled “Actions”Actions should be represented by an interface similar to:
ActionProvider Execute(ActionContext) errorPotential providers:
kuberneteshttpnotificationThe core engine should not contain provider-specific action implementations.
Kubernetes actions
Section titled “Kubernetes actions”Initial Kubernetes actions:
- type: kubernetes.scale target: kind: Deployment # or StatefulSet name: qbittorrent replicas: 0Reads and writes go through the scale subresource rather than the kind’s own
spec.replicas. /scale is the interface that says “this object has a replica
count” without saying where it is kept, so one executor serves every scalable
kind: adding one is an entry in the handler registry, an entry in the CRD enum
and an RBAC rule, and no new executor code.
target.kind stays a closed enum anyway, which is the deliberate half of
the trade-off. The gain from opening it would be imaginary: a kind is only
reachable if the chart granted RBAC for it, and RBAC has to name resources
explicitly, so an open field would accept a kind the operator cannot touch and
report a typo as a Forbidden during the incident rather than as a rejected
write at admission. The enum is the same decision as the chart’s rule list,
written where the API server can enforce it.
Restart
Section titled “Restart”Shipped, as an edge action. There is no value a workload can be held at that means “restarted”, so it declares no level, participates in no arbitration, and fires on its own Automation’s transition:
- type: kubernetes.restart target: kind: Deployment # or StatefulSet name: qbittorrentIt stamps kubectl.kubernetes.io/restartedAt on the pod template — the same
annotation kubectl rollout restart writes, so a workload Reactor restarted
and one restarted by hand are indistinguishable afterwards, and the workload
controller rolls the pods under the update strategy and disruption budget the
workload already declares. Reactor never deletes a pod.
At-most-once, unconditionally. This is the first non-idempotent action, and
it is the reason #33’s retry policy is per-type rather than global. Every
execution rolls the workload, so retrying after an ambiguous failure is a
second outage rather than a correction, and the failures that actually occur —
a conflict, a Forbidden — are not ones a retry fixes. It is attempted once per
transition, recorded in status.edgeActions, and never retried: not within the
reconcile, and not across reconciles, because the transition is committed to
status before the action runs and a later reconcile therefore sees no edge.
It is also what makes #30’s debounce load-bearing. The engine acts on
transitions, so a steady condition never restarts twice — but a flapping key is
a stream of transitions and each one is a real rollout. Scaling made flapping
harmless; this does not. The default debounce of 1 was chosen for
kubernetes.scale, and a key that drives a restart should be raised above it,
at a cost of one poll interval of reaction time per extra sample.
Suspend CronJob
Section titled “Suspend CronJob”Shipped. Desired-state, and the first action whose level is a switch rather than a count:
- type: kubernetes.cronjob.suspend target: kind: CronJob name: large-backup suspended: true # optional; true is the defaultengine.Resolve was deliberately not generalised over an ordered type
parameter to accommodate it. A switch is a two-element lattice, and embedding
it in the integers as “suspended is 0, running is 1” is order-preserving, so
the meet stays min and “most restrictive wins” stays “suspended wins” without
the engine learning a second kind of value. A target has exactly one kind, so
two levels in different units never meet in the first place — the generality
would have had no case to serve.
The baseline is recorded under reactor.robbeverhelst.com/baseline-suspend,
not under baseline-replicas. That annotation is a compatibility promise about
replica counts as of v1.0, and a reader — a person, or a script over kubectl get -o custom-columns — is entitled to keep reading "1" there as one
replica. A kind whose level is not a count records it under its own name.
Suspending does not stop a Job already running, and deliberately grants no permission over Jobs at all: declining to start more work is a categorically safer act than killing work in flight, and deleting in-flight Jobs is not something an outage should decide on the operator’s behalf.
Cordon — and why there is no drain
Section titled “Cordon — and why there is no drain”kubernetes.cordon is shipped, as a desired-state action behind an explicit
chart opt-in:
- type: kubernetes.cordon target: kind: Node name: worker-03 cordoned: true # optional; true is the defaultspec.unschedulable is a level, ordered so that cordoned is the restrictive
answer, so it folds like every other level and needs no new rule. It is the
first cluster-scoped target, which is why target.namespace is rejected on a
Node rather than defaulted.
kubernetes.drain — proposed in #18 alongside it — is deliberately not
implemented. Not deferred behind a flag, not implemented with a timeout: not
built. The reasoning, because a well-evidenced “no” is worth more here than a
dangerous feature:
- An eviction cannot be reversed, so it cannot be a level. Every action in
this design declares a value that is a pure function of which conditions
currently hold — that is what makes the outcome independent of reconcile
order, a controller restart harmless, and
onExitexpressible. A drain has no such value. There is no state a node can be held at that means “drained”, nothing forspec.reversalto declare, and nothing for a later reconcile to correct. A flapping key would empty the node once per flap. - It inverts its own goal on a small cluster. Draining assumes spare capacity elsewhere. On three nodes behind one UPS the evicted pods do not move, they go Pending — so the workload is lost at the moment of the drain rather than at the moment the battery dies. Cordoning delivers the actual benefit (new pods land on the node still on mains) without that cost.
- It can evict the operator performing it, if Reactor’s own pod is on the node. Nothing else here can kill the thing doing and reporting the work, mid-action.
- It hangs by design. Eviction respects PodDisruptionBudgets, and a
single-replica workload with
minAvailable: 1blocks indefinitely. A timeout bounds the call; it does not bound a half-drained node.
The RBAC follows the decision rather than merely reflecting it:
rbac.allowNodeActions grants nodes and nothing over pods or
pods/eviction, under any setting. An operator whose plan genuinely needs a
drain should pair kubernetes.cordon with a notification and run kubectl drain by hand — an irreversible cluster-wide decision at 3am is the right
place for a human.
Patch resource
Section titled “Patch resource”Potential future action:
- type: kubernetes.patch target: kind: Deployment name: exampleDo not initially expose unrestricted arbitrary Kubernetes operations.
Actions should be explicit and auditable.
HTTP actions
Section titled “HTTP actions”A generic HTTP action is extremely useful.
Example:
- type: http.request request: method: POST url: https://example.internal/hookSupport:
- GET
- POST
- PUT
- PATCH
Credentials should come from Kubernetes Secrets rather than being stored directly in Automation resources.
As shipped.
http.requestandnotification.ntfy/notification.discord/notification.slackexist, and are the first edge actions — they fire on a transition rather than declaring a level, so they take no part in target arbitration. Two things about them are narrower than this section implies, and deliberately so:
- Destinations are allowlisted at install time and refused by default.
spec.actionsis writable by anyone who can create anAutomation, and the request goes out with the operator’s network position rather than the author’s, so which hosts that is worth is not a per-Automation decision. The loopback interface and link-local addresses are refused whatever the allowlist says, and redirects are not followed.- Only the body is templated. The URL and the headers are literal, because letting observed state edit the destination would give back the choice the allowlist exists to take away.
Credentials come from a Secret in the
Automation’s own namespace — never inline, never cross-namespace. See SECURITY.md for the threat model and the README for the shape.
Named integrations over the generic action
Section titled “Named integrations over the generic action”homeassistant.service is shipped, and is the first action to sit on top of
http.request rather than beside it. This section’s instinct — prefer the
generic action, do not build a provider per service — is right, and the shape
that follows from taking it seriously is not “no integration” but “an
integration that is a shape over the one transport”.
There is exactly one outbound HTTP client. A named integration decides two things — what the URL is and what the body is — and inherits the destination allowlist, the address floor in the dialer, the redirect refusal, the origin-only reporting and the Secret rules. Adding a second client would be the actual mistake this section is warning about, because each of those is a place to get security wrong once per client.
The bar for naming an integration, from the two that cleared it:
- It constrains the request.
homeassistant.servicebuilds its path from adomainand aservice, each a bare slug. That is strictly less reach than thehttp.requestthe same allowlist entry already permits, and it makes the action state what it is. - Or the exchange is not one request. See qBittorrent below.
Neither “it is convenient” nor “it is popular” is on that list. An integration
that would be a url and a body an author could have written themselves is a
documented example, not an action type.
qBittorrent
Section titled “qBittorrent”Do not initially create a qBittorrent-specific action provider unless it is necessary.
The first implementation can use the generic HTTP provider or a small integration later.
However, qBittorrent is an important reference use case.
The desired behavior:
WAN → backup ↓pause torrents
WAN → primary ↓resume torrentsThis should be demonstrated in documentation using a single state-triggered Automation with onExit.
As shipped, as
qbittorrent.pauseandqbittorrent.resume. This section’s instruction was to prefer the generic HTTP action, and the first step was to prove whether it can express this. It can only under one configuration, and the reason it cannot in general is what justifies the action.The generic-HTTP attempt. qBittorrent’s WebUI authenticates with a session cookie from
POST /api/v2/auth/login, not with a static token.http.requestis one request: it holds no cookie jar, follows no redirect, and there is no value that could be put in a Secret ahead of time, because theSIDdoes not exist until the login happens. So:
- With authentication bypassed for the calling subnet, a plain
http.request—POST {base}/api/v2/torrents/pause, bodyhashes=all— works, and is documented as the right answer for that setup.- With authentication on, which is the default and the only sensible posture for something reachable from the cluster, it cannot be expressed at all. Not awkwardly: at all.
That is the bar the named integrations section states — the exchange is not one request — and it is met. What was not built is a second HTTP client:
Sessionis a field on the existing request, so the login, the action and the logout all go through the same allowlist check, the same dialer floor, the same redirect refusal and the same origin-only reporting.The session and the credential rule. A
SIDis a bearer of the same authority as the password, so caching one across reconciles would be exactly what this project refuses to do with the password. There is no session cache. The login happens inside the one action, the cookie lives in a local variable, and a logout ends the session on the far end rather than leaving it to expire. A retry re-runs the whole exchange.
Pausing is a level, and this is an edge action anyway
Section titled “Pausing is a level, and this is an edge action anyway”The interesting part of #21 is not the HTTP. Paused-versus-running is a level, and a level is the thing this design arbitrates — so the action looks like a desired-state action and is not one.
What makes a desired-state action possible is not the fold. It is that the target is a Kubernetes object, so the value it held before Reactor claimed it can be recorded as an annotation on that object, where it outlives the Automation, outlives Reactor, and is readable by the pre-delete sweep during an uninstall. That baseline is what makes release possible, and release is what makes a claim legitimate.
A qBittorrent instance reached over HTTP has no Kubernetes identity to arbitrate over, no annotation to hold a baseline, and nothing the uninstall hook could reach if it did — that hook runs with no credentials and no destination allowlist, by design. The alternatives were considered and rejected in the README: status dies with the Automation, a tag written into the user’s torrent client is both editable by them and unreadable by a client that never parses a response body, and a synthesized identity would arbitrate on string equality of a URL.
So it ships as an edge action, named as a verb, with two limitations stated
rather than hidden: it is not arbitrated, and resume resumes torrents that
were paused by hand because nothing recorded which those were.
The general conclusion, which is the durable part. Non-Kubernetes desired-state targets need a design that does not exist yet: somewhere to keep a baseline and a claim for a thing with no object to hang them on, and some way for the uninstall path to reach it. Until that exists, the rule is that a desired-state action targets a Kubernetes object, and anything else is an edge action named as a verb. That is a rule about the whole action taxonomy, not about torrents.
UniFi console actions
Section titled “UniFi console actions”Shipped as unifi.wlan.enable / unifi.wlan.disable (#24) and
unifi.poe.cycle (#25). They are the first actions that write to the
console the provider observes, rather than to the cluster or to an address an
automation named, and three things about them are decisions rather than
implementation.
The rule above was applied and produced the same answer. A WLAN being
enabled is a level in the world exactly as pausing torrents is, and it is an
edge action for the same reason: there is nowhere to record what it was before
Reactor changed it. Writing that into the WLAN’s own configuration is the
torrent-tag rejection again — it is the user’s config, editable by them, and the
write carrying it is a read-modify-write with no concurrency control. And the
release half is worse here than for qBittorrent: handing a WLAN back means a
credentialed write to the console, and the pre-delete sweep runs with no
credentials by design. So the actions are named as verbs, they arbitrate with
nothing, and the fact that a disabled network stays disabled if the exit
transition never arrives is stated in the CRD doc, the README and the chart
values rather than discovered. unifi.poe.cycle needs no argument at all: a
cycle has no level.
The destination control is the console-side equivalent of
actions.allowedDestinations. spec.actions is writable by anyone who can
create an Automation in their own namespace, so what may be changed on the
console is decided by the operator at install time —
unifi.actions.allowedWlans and unifi.actions.allowedPoePorts, both empty by
default, both refusing everything, with no per-automation override. A PoE entry
names a switch MAC and a port index, because an index alone means something
different after somebody re-patches a rack.
Identity is checked before every write, and some refusals are floors. A port is identified by MAC, index and name, all three confirmed against the switch’s own port table immediately before the command is sent; the switch’s uplink port and any non-PoE port are refused whatever the allowlist says, the way the outbound dialer refuses loopback whatever the destination allowlist says. A switch that does not report those fields is refused rather than assumed safe.
The retry policy is at-most-once for all three, recorded with the others on
maxActionAttempts. The loop #25 warns about — an AP that fails to come back
being bounced repeatedly — cannot occur, because the automation stays matched
and matched is not a transition. Flapping can still drive repeated cycles, and
the answer there is the engine’s debounce rather than a cooldown inside the
action; a private cooldown would be a second, weaker debounce invisible to
everything beside it.
Every endpoint on this path is inferred, and
Writing to a UniFi console splits what was observed on a real
console from what was not. hack/mock-unifi serves and enforces the write
endpoints so the path is exercised at all.
State is the primary abstraction, not a future feature.
An event:
wan.failovermeans:
The WAN failover event occurred at some moment.
State:
wan = backupmeans:
The WAN is currently operating through the backup connection.
Reactor reconciles Automations against current observed state, with the following properties:
- On controller startup, state is observed immediately and Automations converge, even if failover happened while the controller was down.
- Repeated identical observations are no-ops.
onExitactions run when a previously-matching state condition stops matching.- Automatic reversal is only ever what the user declared in
onExit, and eachonExitexecution is recorded in status for auditability. Reversal is powerful and can be dangerous; it must always be explicit and observable.
Genuine point-in-time events remain supported through trigger, but nothing that has an observable current value should be modeled as an event only.
Reliability
Section titled “Reliability”This is an infrastructure automation system.
Reliability matters.
The state-first design already provides much of this for free for stateful automations: missed deliveries, duplicate deliveries, and controller restarts all resolve on the next observation. The mechanisms below mostly protect the event path and action execution.
Event IDs
Section titled “Event IDs”Every event should have a unique ID.
Deduplication
Section titled “Deduplication”Duplicate webhook deliveries should not cause duplicate destructive actions. For state triggers this is inherent (state comparison). For event triggers, deduplicate on event ID within a bounded window.
Failed actions should be retried with bounded exponential backoff.
As shipped, the policy differs by action kind, because the kinds fail differently. A desired-state action is idempotent by construction, so the reconcile loop is itself the retry, bounded at five consecutive failures. An edge action fires on an occurrence that has already passed, so it is never retried across reconciles — a later reconcile has no new transition, and re-sending there would be a duplicate rather than a retry. Whether it may be repeated within its one reconcile is decided per type: notifications are publishes and retry three times, and an
http.requestretries only when the method is idempotent (GET,PUT) or the author declares it so.
Timeouts
Section titled “Timeouts”Actions must have configurable timeouts.
Idempotency
Section titled “Idempotency”Actions should be designed to be safe when retried.
For example:
scale deployment → replicas=0is preferable to:
send "scale down" commandbecause repeating the first operation has predictable behavior.
Persistence
Section titled “Persistence”State observations are re-derived from the source on startup, so no external database is needed for the core loop.
Consider whether event processing needs persistence across controller restarts. Kubernetes resources/status and a lightweight internal queue should be preferred initially. Do not introduce an external database unless necessary.
Security
Section titled “Security”Security is a major requirement.
The webhook endpoint must not be an unrestricted remote command interface.
Never allow:
action: command: "rm -rf ..."or arbitrary shell execution by default.
Kubernetes actions must use a dedicated ServiceAccount with minimal RBAC.
For example, if an Automation only needs to scale Deployments, the controller should not require cluster-admin.
Secrets must be stored in Kubernetes Secrets. This includes the UniFi API credentials used for state polling.
Webhook authentication should be supported.
Potential mechanisms:
shared secretHMAC signatureauthorization headerUse the mechanism supported by UniFi where possible.
The operator should follow least privilege.
If the controller needs:
get deploymentspatch deploymentsgive it exactly those permissions.
As shipped, a target kind costs get and patch and nothing else. Targets are
read as unstructured objects through the manager’s uncached client, so no
target kind starts an informer — which is what would have made list and
watch necessary, and would have held every object of that kind in the
operator’s memory to answer a question asked about one of them every fifteen
seconds.
Do not ship:
cluster-adminunless there is a compelling reason.
Anything reaching outside the workloads Reactor was installed to manage is
opt-in rather than default, and is a separate object so that turning it on is a
visible decision. kubernetes.cordon is the only such permission today: nodes
are cluster-scoped, so rbac.allowNodeActions creates a ClusterRole even in a
namespace-scoped install, and the chart says so where the value is set. The
generated config/rbac/role.yaml deliberately does not carry it, so the
manifest bundle grants no node access at all.
Document how users can restrict Reactor’s permissions.
Observability
Section titled “Observability”The controller should be easy to debug.
Provide:
Kubernetes status
Section titled “Kubernetes status”Example:
status: phase: Ready observedState: wan: backup lastTransition: from: primary to: backup time: "2026-08-11T10:00:00Z" lastExecution: status: SuccessStructured logs.
Example:
INFO state observed provider=unifi wan=backupINFO state transition provider=unifi key=wan from=primary to=backupINFO automation matched automation=degrade-on-backup-wanINFO executing action type=kubernetes.scale target=deployment/qbittorrentINFO action completed status=successMetrics
Section titled “Metrics”Eventually expose Prometheus metrics such as:
reactor_events_totalreactor_state_transitions_totalreactor_automation_matches_totalreactor_actions_totalreactor_action_failures_totalreactor_action_duration_secondsDo not block the first MVP on an extensive metrics system.
Status and conditions
Section titled “Status and conditions”Automation resources should expose useful Kubernetes conditions.
For example:
status: conditions: - type: Ready status: "True" reason: ValidInvalid configuration should result in:
status: conditions: - type: Ready status: "False" reason: InvalidConfigurationThe controller should not repeatedly attempt to execute invalid automations.
Installation
Section titled “Installation”The project should eventually support:
helm repo add reactor ...helm install reactor ...and ideally:
kubectl apply -f https://...Installation should create:
- CRDs
- ServiceAccount
- RBAC
- Deployment
- Service
- webhook endpoint
A Helm chart should be maintained from the beginning.
CI and releases
Section titled “CI and releases”The project must be installable from public artifacts from the first tagged release. GitHub Actions is the assumed platform.
CI (every PR and push to main)
Section titled “CI (every PR and push to main)”lint golangci-linttest unit + envtestbuild controller binarydocker-build image build (no push)chart-lint helm lint + helm templatemanifests verify generated CRDs/deepcopy are committed (make manifests produces no diff)CI must be green before anything can be released.
Release pipeline (on version tag)
Section titled “Release pipeline (on version tag)”Tagging vX.Y.Z should produce, automatically:
1. multi-arch container image (amd64 + arm64, homelabs run on both) → ghcr.io/<owner>/unifi-reactor:vX.Y.Z2. Helm chart with matching appVersion → pushed as OCI artifact to ghcr.io/<owner>/charts/reactor3. install manifest bundle (install.yaml) attached to the GitHub Release → enables: kubectl apply -f https://github.com/.../releases/download/vX.Y.Z/install.yaml4. GitHub Release with generated changelogNotes:
- Prefer OCI chart hosting on GHCR over a gh-pages chart repo; it is one less moving part and
helm install oci://...is well supported now. - Use release-please (or equivalent) with conventional commits to automate version bumps and changelogs, matching the workflow already used for existing open-source packages.
- Image and chart versions must always move together; the chart’s
appVersionis set from the tag in the pipeline, never by hand. - Sign images and charts (cosign keyless via GitHub OIDC) once the pipeline is stable; do not block v0.1 on it.
- No release steps run on a developer machine. If it is not reproducible from a tag in Actions, it is not the release process.
Repository structure
Section titled “Repository structure”Suggested initial repository:
unifi-reactor/├── .github/│ └── workflows/│ ├── ci.yaml│ └── release.yaml│├── api/│ └── v1alpha1/│ ├── automation_types.go│ └── zz_generated.deepcopy.go│├── internal/│ ├── controller/│ │ └── automation_controller.go│ ││ ├── engine/│ │ ├── engine.go│ │ ├── matcher.go│ │ ├── state.go│ │ └── executor.go│ ││ ├── events/│ │ ├── event.go│ │ └── bus.go│ ││ ├── providers/│ │ ├── unifi/│ │ ├── kubernetes/│ │ └── http/│ ││ └── webhook/│ └── server.go│├── config/│ ├── crd/│ ├── rbac/│ └── manager/│├── charts/│ └── reactor/│├── examples/│ ├── unifi-wan-failover/│ ├── kubernetes-scale/│ └── ...│├── testdata/│ └── unifi/ # real captured UniFi payloads and API responses│├── test/│├── Dockerfile├── Makefile├── go.mod└── README.mdUse standard Kubernetes Operator conventions.
A Go implementation using Kubebuilder/controller-runtime is recommended unless there is a strong technical reason otherwise.
Development principles
Section titled “Development principles”Keep the core generic
Section titled “Keep the core generic”The engine must not contain code like:
if event.Provider == "unifi" { ...}Instead:
Provider → normalized Event / State → EngineUniFi-specific logic belongs in the UniFi provider.
Don’t over-generalize the API
Section titled “Don’t over-generalize the API”Do not design 20 CRDs before implementing the first use case.
Start with:
Automationand a clean provider/action abstraction.
Add abstractions only when a real second provider requires them.
The first usable version should support exactly this:
UniFi WAN state (poll + webhook fast path) ↓state normalization ↓Automation matching (when: state) ↓Kubernetes action (+ onExit)Specifically:
Trigger
Section titled “Trigger”unifi state wan = backupAction
Section titled “Action”scale Deployment (with onExit reversal)Example:
apiVersion: reactor.robbeverhelst.dev/v1alpha1kind: Automationmetadata: name: pause-qbittorrent-on-backup-wanspec: when: provider: unifi state: wan: backup
actions: - type: kubernetes.scale target: kind: Deployment name: qbittorrent replicas: 0
onExit: - type: kubernetes.scale target: kind: Deployment name: qbittorrent replicas: 1This single resource covers both failover and recovery. It should work end-to-end before implementing additional features.
Add:
event triggers (trigger:)HTTP actionsSecretsretryevent deduplicationstatusmetricsFuture providers
Section titled “Future providers”Once the UniFi integration is mature, add providers based on real demand.
Potential providers:
For UPS state:
ups: online | on-battery | low-battery | criticalExample:
UPS enters battery mode ↓Reactor ↓scale nonessential workloads to zeroNote that UPS status is a textbook state, which is another reason state is the primary abstraction.
Proxmox
Section titled “Proxmox”Potential events/state:
VM started / stoppednode: online | offlinePrometheus
Section titled “Prometheus”Allow alerts to trigger Reactor automations. Prometheus alerts are themselves stateful (firing/resolved), which maps cleanly onto when / onExit.
Webhook
Section titled “Webhook”A generic webhook provider makes almost anything integratable.
Home Assistant
Section titled “Home Assistant”Useful for homelab and home infrastructure events.
Future actions
Section titled “Future actions”Potential actions:
Kubernetes├── scale├── restart├── patch├── suspend CronJob└── rollout
HTTP├── GET├── POST├── PUT└── PATCH
Notifications├── Discord (shipped)├── Slack (shipped)├── ntfy (shipped)├── Telegram└── Email
Infrastructure├── Proxmox├── qBittorrent├── Home Assistant└── ...These should only be added when they solve real use cases.
Example automations
Section titled “Example automations”Pause downloads while on backup WAN (with automatic resume)
Section titled “Pause downloads while on backup WAN (with automatic resume)”apiVersion: reactor.robbeverhelst.dev/v1alpha1kind: Automationmetadata: name: pause-downloads-on-backup-wanspec: when: provider: unifi state: wan: backup
actions: - type: kubernetes.scale target: kind: Deployment name: qbittorrent replicas: 0
onExit: - type: kubernetes.scale target: kind: Deployment name: qbittorrent replicas: 1Stop a heavy Kubernetes workload during UPS battery mode
Section titled “Stop a heavy Kubernetes workload during UPS battery mode”Future NUT provider:
apiVersion: reactor.robbeverhelst.dev/v1alpha1kind: Automationmetadata: name: reduce-load-on-upsspec: when: provider: nut state: ups: on-battery
actions: - type: kubernetes.scale target: kind: Deployment name: heavy-workload replicas: 0Send a notification when a client connects
Section titled “Send a notification when a client connects”apiVersion: reactor.robbeverhelst.dev/v1alpha1kind: Automationmetadata: name: notify-client-connectspec: trigger: provider: unifi event: client.connected
actions: - type: http.request request: method: POST url: https://example.com/webhookFuture: modes
Section titled “Future: modes”A possible future abstraction:
normaldegradedemergencymaintenanceFor example:
WAN primary → normal
WAN backup → degraded
UPS < 30% → emergencyAutomations could then react to infrastructure modes. Modes are a natural extension of the state model: a mode is derived state computed from provider state.
This is a future concept, not an MVP requirement.
Design for idempotency
Section titled “Design for idempotency”Automations may observe the same state repeatedly or receive duplicate events.
For example:
wan=backup observedwan=backup observedwan=backup observedmust not cause repeated action execution.
Prefer desired-state actions:
replicas = 0rather than imperative actions whenever possible. The engine only executes actions on state transitions, never on repeated identical observations.
Testing
Section titled “Testing”The project must have automated tests.
At minimum:
Unit tests
Section titled “Unit tests”- state observation and transition detection
- event normalization (against real captured payloads in
testdata/) - event matching
- condition evaluation
- action execution (including
onExit) - deduplication
- retries
Controller tests
Section titled “Controller tests”Use envtest/controller-runtime testing.
Integration tests
Section titled “Integration tests”Test:
UniFi state change (simulated API + webhook) ↓Automation ↓Kubernetes DeploymentThe test should verify that a state transition causes the expected Kubernetes state, that repeated observations are no-ops, and that exiting the state runs onExit.
Local development
Section titled “Local development”Provide a simple development workflow.
Ideally:
make testmake lintmake buildmake docker-buildmake installmake deployFor local Kubernetes testing, support a lightweight cluster such as:
kindor:
k3dDocument how to send a fake UniFi webhook locally:
curl -X POST \ http://localhost:8080/webhooks/unifi \ -H 'Content-Type: application/json' \ -d @testdata/unifi/wan-failover.jsonand how to run against a mock UniFi API for state polling.
Documentation
Section titled “Documentation”Documentation should focus heavily on practical examples.
The first page should show:
UniFi WAN failover ↓pause qBittorrent ↓UniFi WAN restored ↓resume qBittorrentachieved with a single copy/pasteable Automation resource.
Documentation should cover:
- Installation
- UniFi API credentials and webhook configuration
- Authentication
- First Automation
- State triggers and
onExit - Event triggers
- Kubernetes actions
- HTTP actions
- Secrets
- Troubleshooting
- Security
- Adding providers
- Adding actions
Project roadmap
Section titled “Project roadmap”v0.0 (spike, before any operator code)
Section titled “v0.0 (spike, before any operator code)”- Capture real UniFi webhook payloads for WAN failover/recovery
- Capture real UniFi Network API responses for WAN state
- Commit both to
testdata/ - Decide final shape of the UniFi provider based on findings
UniFi-first MVP:
- Kubernetes Operator
- Automation CRD (state trigger +
onExit, event trigger schema reserved) - UniFi WAN state poller
- UniFi webhook endpoint (fast-path re-observation)
- State normalization and transition detection
- Kubernetes scale action
- Basic status (observed state, last transition)
- Helm chart
- CI workflow (lint, test, build, chart-lint, manifest drift check)
- Release workflow (multi-arch image to GHCR, OCI chart, install.yaml, changelog)
- Example automation
- Tests
Reliability and events:
- webhook authentication
- event triggers
- event IDs
- deduplication
- retries
- action timeouts
- better status conditions
- Prometheus metrics
- structured logging
- HTTP action
- image/chart signing (cosign keyless)
More UniFi:
- device online/offline
- client events
- additional Network events
- richer matching
- better UniFi documentation
Expand providers based on demand:
- NUT
- Proxmox
- generic webhook
- Prometheus
- Home Assistant
Do not implement every possible provider just because it is listed here.
Long-term vision
Section titled “Long-term vision”The long-term vision is:
Reactor is an event-driven infrastructure automation layer for homelabs and Kubernetes.
It should allow infrastructure to react to infrastructure.
Examples:
UniFi WAN failure ↓degraded network mode ↓pause downloadsUPS on battery ↓reduce workload ↓UPS critical ↓shutdown nonessential infrastructureProxmox node failure ↓react ↓restart workload elsewherePrometheus alert ↓automation ↓restart or scale serviceThe common abstraction remains:
State / Event ↓Match ↓ActionImportant implementation instruction
Section titled “Important implementation instruction”Build the smallest useful system first.
Do not spend the first implementation phase building:
- a UI
- a complicated expression language
- 15 providers
- a database
- a workflow DAG engine
- arbitrary shell execution
- an enormous plugin framework
The first milestone is:
UniFi WAN state ↓poll + webhook ↓Reactor ↓Automation CRD ↓Kubernetes scale action (+ onExit)If this works reliably, expand from there.
The project should be simple to understand, simple to install, and simple to extend.
Branding
Section titled “Branding”Project name:
UniFi Reactor
Future umbrella name:
Reactor
Suggested repository:
unifi-reactorSuggested tagline:
Event-driven automation for UniFi networks and Kubernetes.
Suggested short description:
An open-source Kubernetes Operator that turns UniFi events into infrastructure actions.
The word Reactor should remain part of the branding. It describes the core concept: infrastructure events cause infrastructure reactions.
License
Section titled “License”Use a permissive open-source license suitable for community contributions, preferably Apache-2.0 unless there is a specific reason to choose another license.
First task for the coding agent
Section titled “First task for the coding agent”Before implementing anything:
- Run the v0.0 spike: capture real UniFi webhook payloads and Network API WAN-state responses by triggering an actual failover, and commit them to
testdata/. Everything below depends on what this reveals. Do not proceed on assumed payload formats. - Inspect this specification.
- Choose a standard Kubernetes Operator stack, preferably Go + controller-runtime/Kubebuilder.
- Create the repository structure.
- Implement the Automation CRD with the two trigger kinds (
whenstate trigger withonExitimplemented first;triggerevent schema defined but may be stubbed). - Implement the controller.
- Implement the normalized state and event models.
- Implement the UniFi WAN state poller using the captured API responses.
- Implement the UniFi webhook receiver as a fast-path re-observation trigger, using the captured payloads.
- Implement state matching and transition detection.
- Implement Kubernetes Deployment scaling.
- Add the first end-to-end test.
- Add a Helm chart.
- Add a working example YAML.
- Add documentation explaining how to configure UniFi credentials and the webhook.
- Keep the implementation small and clean.
Do not implement future providers until the UniFi → Automation → Action path is working reliably.
The first success criterion, with a single Automation resource:
UniFi WAN → backup ↓Reactor observes transition ↓Automation matches ↓qbittorrent Deployment replicas → 0Then:
UniFi WAN → primary ↓Reactor observes transition ↓onExit runs ↓qbittorrent Deployment replicas → 1Everything else comes afterwards.