The issues looked simple: wire claudony’s fleet API into the desiredstate reconciliation loop as a NodeProvisioner (#127) and ActualStateAdapter (#128). Both S/Low. Map one API to another. Ship it.
I got about twenty minutes in before the architecture said no.
The desiredstate runtime routes provisioners by NodeType. Each provisioner
declares which types it handles via handledTypes(). The router —
DefaultNodeProvisionerRouter — builds a lookup table at construction time.
If two provisioners claim the same type, it throws IllegalArgumentException
and the app won’t start. Same rule for ActualStateAdapterRouter.
The existing DeploymentNodeProvisioner already handles casehub_application.
A standalone ClaudonyNodeProvisioner handling the same type would crash on
startup. There’s no priority mechanism, no preference annotation, no fallback
— it’s a hard 1:1 constraint.
Issue #117 had already built multi-target routing into the deployment module.
DeploymentApplicationNodeSpec carries a deploymentTargetId field.
CaseHubAppProvisionHandler resolves the target by ID from CDI’s
Instance<ApplicationDeploymentTarget>. The infrastructure for “same node
type, different backend” was sitting there waiting.
So instead of a standalone provisioner, we built ClaudonyDeploymentTarget
implements ApplicationDeploymentTarget with targetId = "claudony". The
mapping is the same one the issues described — deploy calls registerApp,
undeploy calls deregisterApp, status reads health from the fleet API —
but it plugs into existing routing rather than fighting the type system.
Claudony supports stateful lifecycle — suspend an app without destroying it,
resume it later. The NodeProvisioner interface has suspend() and resume()
methods, but ApplicationDeploymentTarget didn’t. We extended the interface
with default methods that return Failed by default, so existing targets
(in-process, Podman, Kubernetes) are unaffected. The claudony target overrides
them to call suspendApp and resumeApp on the fleet API.
The DeploymentNodeProvisioner now delegates suspend/resume through the same
handler chain as provision/deprovision. When the spec carries
deploymentTargetId = "claudony", it resolves to the claudony target and the
suspend goes through.
Claude caught something during the robustness audit: ClaudonyDeploymentTarget
with bare @ApplicationScoped would suppress the @DefaultBean fallback
(NoOpDeploymentTarget) and become the default target for all deployments.
Every app would route through claudony whether it was meant to or not. A
@Named("claudony") qualifier fixed it — the target is still discoverable via
@Any Instance<> but doesn’t hijack the default injection point.
The claudony/ module is self-contained: a ClaudonyFleetClient interface,
an HttpClaudonyFleetClient implementation using Java’s built-in HttpClient
(no Quarkus REST client dependency — keeps the module lightweight), mirror DTOs
for claudony’s ApplicationSpec and ApplicationInfo, and the deployment
target itself.
The drift checker (CaseHubAppDriftChecker) got multi-target resolution —
when checking a DeploymentApplicationNodeSpec with a deploymentTargetId,
it resolves the correct target rather than always querying the default. This
means claudony-targeted apps get their drift checks from the claudony fleet
API, not the in-process or Podman backend.
This is recorded as ADR-0002. The short version: standalone provisioner was
the obvious approach from the issue text, but the type-collision constraint
made it impossible without removing casehub_application from the deployment
module (which handles seven other types) or going classpath-exclusive (which
forces all-or-nothing target selection). The ApplicationDeploymentTarget SPI
was the right level of abstraction — it was purpose-built for exactly this kind
of backend variation.