All notable Percona patches to hetzner-cloud-plugin are documented here.
Hardens the v103.percona.29 retention fix based on a multi-model adversarial review before the fleet rollout (v29 was released but never deployed; v30 is the version that rolls out).
hetzner_agents_idle_overdueexcludes hour-wrap agents. Their policy has no idle-shutdown period (a worker may legitimately idle until the end of its billing hour), so the previous 20-minute threshold would have emitted up to ~35 minutes of false reap-failure signal per hour for every idle hour-wrap agent. Idle-period agents keep the twice-idleMinutes threshold, floored at 20 minutes; covered by a new threshold unit test.IdlePeriodPolicyis nowfinaland itsreadResolve()isprivate. The protected hook on an extensible class was the classic readResolve-subclassing trap: XStream would invoke the inherited parent hook on any future subclass and silently replace it with a plainIdlePeriodPolicy, losing subtype state.- The reap-health gauge docs now state the observable semantics (effective strategy is Always) instead of the unobservable null field, and the refresh failure handler logs the full stack trace instead of a possibly-empty exception message.
- The metrics refresher no longer skips the retention gauges while the API
token is rate-limited.
HetznerCloud.refreshLocalMetrics()(pending provisions + reap-health gauges, no API calls) now runs every cycle; only the API-backedhetzner_running_serversrefresh is gated. Before this, a 429 window froze the reap-health gauges at their pre-window values, hiding a newly immortal agent or pinning a stale alert.
Fixes shutdown-policy retention loss on deserialization, which produced immortal workers, and adds two reap-health gauges so the failure mode is alertable instead of silent.
Root cause: AbstractShutdownPolicy.retentionStrategy is transient and only
assigned in the constructor, which XStream bypasses. A HetznerCloud loaded
from the controller's persisted config.xml therefore carries
IdlePeriodPolicy instances with idleMinutes intact but a null wrapped
CloudRetentionStrategy. HetznerServerAgent bakes
template.getShutdownPolicy().getRetentionStrategy() into the node at
creation, and core Slave.getRetentionStrategy() maps a null field to
RetentionStrategy.Always, so every agent provisioned or rehydrated from a
deserialized template is never reaped. Observed fleet-wide on 2026-08-07:
cloud.cd 16/16 agents immortal (oldest 58 days, disks at 100% from
accumulated buildx debris), ps80.cd 13/13, pxc.cd 2/2. This is the
deserialization bug class from v103.percona.1/.25/.27 surfacing in the
shutdown path.
Fix: IdlePeriodPolicy.readResolve() rebuilds the instance so the transient
strategy is restored after deserialization, and the HetznerServerAgent
constructor falls back to the default idle policy when a template still hands
it a null strategy (belt and braces; BeforeHourWrapsPolicy already returns
its strategy singleton from the getter and is immune). Existing immortal
agents are not fixed by upgrading: cure them in place via Script Console
(setRetentionStrategy(new CloudRetentionStrategy(idleMinutes))) or recycle
them.
Metrics: hetzner_agents_retention_missing (agents whose effective retention
is Always; should be constant 0) and hetzner_agents_idle_overdue (online
idle agents past twice their idle-shutdown period; catches any reap-failure
mode by symptom, including a dead ComputerRetentionWork timer). Both are
per-cloud gauges refreshed by the existing 1-minute
HetznerMetricsRefresher pass.
Resolves SSH credentials by the SSHUserPrivateKey interface instead of the
concrete BasicSSHUserPrivateKey, so credentials served by alternative providers
(notably the AWS Secrets Manager credentials provider, which exposes
AwsSshUserPrivateKey) are visible to the Hetzner cloud. This matches the
contract the EC2 and EC2-Fleet plugins already use.
Root cause: Helper.assertSshKey looked up
CredentialsProvider.lookupCredentialsInItemGroup(BasicSSHUserPrivateKey.class, ...),
filtering on the concrete implementation class. AwsSshUserPrivateKey implements
the SSHUserPrivateKey interface but does not extend BasicSSHUserPrivateKey, so
it was filtered out at the source and provisioning failed with
IllegalStateException: No SSH credentials found with ID '<id>', even though the
credential resolved correctly for every other consumer. The concrete-class lookup
also hid such credentials from the connector's config dropdown.
Fix: look up the SSHUserPrivateKey interface at all four sites (Helper.assertSshKey,
the two HetznerServerComputerLauncher connection paths, and the
AbstractHetznerSshConnector config dropdown), and read the key material via the
interface getPrivateKeys() accessor rather than the deprecated singular
getPrivateKey(). The change is strictly widening: every BasicSSHUserPrivateKey
is an SSHUserPrivateKey, so existing direct-entry and JCasC keys keep working
unchanged; only interface-only implementations gain visibility. The
SSHAuthenticator<Connection, ...> generic already binds
U extends StandardUsernameCredentials, so it accepts the interface without
change, and the alternative provider's lazy value fetch is preserved (the key is
read only at connect time). Candidate for upstream contribution to
jenkinsci/hetzner-cloud-plugin, which carries the same concrete-class coupling.
Scopes ghost-node cleanup to the owning cloud, closing a multi-cloud
mass-deletion regression. Ported from upstream commit 5a7a304, a fix that
upstream layered on top of our own CRW-timer-death patch (contributed back to
jenkinsci and shipped there as v106 / commit 796d19b).
Root cause: the bi-directional OrphanedNodesCleaner (added in v103.percona.1)
compares Helper.getHetznerAgents(), which returns agents from ALL Hetzner
clouds, against fetchAllServers(cloud.name), which returns VMs for a single
cloud. On a controller configured with more than one HetznerCloud, the
per-cloud cleanup pass treats every agent owned by every other cloud as a
"ghost node" and removes it, killing active builds every hour. The transient
cloud field on HetznerServerAgent is null after deserialization, so the
cleaner could not previously tell which cloud owns which agent.
Fix: add a persistent cloudName field to HetznerServerAgent (survives
restart/deserialization; serialVersionUID 1 to 2) and scope the ghost-node
comparison to agents owned by the cloud being cleaned, so each cloud's pass
only considers its own agents. Ownership is resolved by
OrphanedNodesCleaner.ownerCloudName(): the persistent cloudName field,
falling back to the cloud name carried by the persistent provisioningId for
agents provisioned before that field existed. This goes one step beyond
upstream's port, which skips (and therefore leaks) legacy agents whose
cloudName is null; the provisioningId fallback lets the owning cloud still
reap them.
The Percona fleet runs one HetznerCloud per master, so the regression is
latent today; the fix ships as defense-in-depth before any multi-cloud topology
lands. Tests: OrphanedNodesCleanerTest proves ghost removal is scoped per
cloud and that legacy (null-cloudName) agents are attributed via
provisioningId; HetznerServerAgentTest covers the new field plus the
v103.percona.1 _terminate()/isAlive() null-transient guards, which the fork
previously had no test for.
Closes the OPEN-breaker API storm path uncovered by the 2026-05-22 cax arm64
shortage incident. With all three Hetzner arm64 DCs OPEN on ps80.cd (cax
capacity event, same pattern as PS-11149), the plugin sustained ~35 API
requests/minute trying to provision against unhealthy DCs. That single
master alone drained the Hetzner project-level API budget shared across the
whole fleet, dropping hetzner_api_rate_limit_remaining from ~3599 to
single-digit headroom on all 10 masters in ~30 minutes (cross-master Pearson
r = +0.96 over the 21:00-22:30 UTC window).
Root cause: DcHealthTracker.sortByHealth sorts matching templates by
breaker health but does NOT filter unhealthy ones, so
HetznerCloud.provision() picked rankedTemplates.get(0) even when all
were OPEN; NodeCallable.call() then iterated the full list calling
createServer() per template with no per-template isHealthy() gate; and
the HALF_OPEN probe was not single-use, so concurrent provision() calls
stampeded the API on every reset window.
DcHealthTracker.filterHealthy(List<HetznerServerTemplate>)andDcHealthTracker.isHealthy(HetznerServerTemplate)helpers. Used byHetznerCloud.provision()to short-circuit beforeeffectiveNodeCount()(thefetchAllServersAPI call) when all matching templates' breakers are OPEN.sortByHealthis preserved as-is for diagnostic visibility.DcCircuitBreaker.isProbeable()(non-consuming peek) andDcCircuitBreaker.tryAcquireProbe()(consuming lease acquisition). Replaces the previous singleisHealthy()method.isProbeable()is used byfilterHealthyandsortByHealthso a filter pass cannot steal the HALF_OPEN probe lease from the actual provisioner.tryAcquireProbe()is called insideNodeCallable.call()at the per-template gate (the actual createServer attempt site); it consumes the lease so exactly one concurrent caller per HALF_OPEN window reaches the Hetzner API. Bounds the per-reset-window API spend on a still-broken DC.DcCircuitBreaker.HALF_OPEN_STALE_TTL_MS(2 * RESET_TIMEOUT_MS = 10 minutes). If a probe-holder consumes the lease and then dies (thread crash, non-DC-attributable exit path that does not call recordSuccess / recordFailure), the lease is re-armed on the next probe call after the TTL elapses. Prevents the breaker from being pinned in HALF_OPEN forever. New counterhetzner_dc_health_stale_half_open_resets_total{location, arch}observes how often this fires.HetznerMetricProvider.REASON_*constant family forPROVISION_SKIPPEDreasons (jenkins_quieting,rate_limited,template_suppressed,cap_reached, and newno_healthy_dc), plusALL_PROVISION_SKIPPED_REASONSenumeration mirroring the existing outcome convention.HetznerMetricProvider.OUTCOME_DC_BREAKER_OPEN = "dc_breaker_open"onPROVISION_ATTEMPTS. Emitted by NodeCallable's per-template gate when a template's breaker is OPEN at iteration time. Added to bothALL_PROVISION_OUTCOMESandPRECHECK_OUTCOMES(no boot work performed) so dashboards do not pollute boot-duration percentiles.
HetznerCloud.provision()now filtersmatchingTemplatesto healthy templates viaDcHealthTracker.filterHealthybefore callingeffectiveNodeCount(). If empty, incrementsPROVISION_SKIPPED{reason="no_healthy_dc"}and breaks the excessWorkload loop. Filtering is per-loop-iteration so a breaker that trips during this provision() call short-circuits the next iteration. The filtered (healthy) list is passed intorankTemplatesByHealthand then intoNodeCallable, so the inner failover loop only ever iterates healthy DCs.HetznerCloud.provision()4 existingPROVISION_SKIPPED.labels(name, "<inline-literal>")call sites refactored to use the newREASON_*constants. Wire-format identical (same string values).NodeCallable.call()per-template breaker gate: at iteration head, before anycreateServercall, callDcHealthTracker.tryAcquireProbe(template)(the consuming variant that takes the HALF_OPEN probe lease at the actual API attempt site). If false (CLOSED is always true; OPEN or HALF_OPEN-lease-taken returns false), emitPROVISION_ATTEMPTS{outcome="dc_breaker_open"}, respect the existingisFailoverCompatibleWithinvariant before advancing to the next ranked template (same gate as the DC-attributable failure branch), andcontinueif compatible. Belt-and-suspenders for breakers that open between provision()'s filter and the per-template iteration.DcCircuitBreaker.isHealthy()removed in favour of the non-consuming / consuming split (see Added). Callers inDcHealthTrackerand tests updated. Behaviour preserved for the consuming callers (tryAcquireProbe()is exactly the oldisHealthy()semantics); filterHealthy / sortByHealth now use the non-consumingisProbeable()so a filter pass cannot steal the HALF_OPEN probe.DcCircuitBreaker.getState()lazy-reset path also arms the probe lease, so a script-console getter call followed by a tryAcquireProbe() in the same HALF_OPEN window correctly gives the probe to the provisioner.
OrphanedNodesCleaner.cleanCloud(): hourlyfetchAllServersmust run during a DC outage to reap orphan VMs and remove ghost Jenkins nodes. Keep the token-levelisRateLimited()gate. 10 calls/hour fleet-wide, not a storm contributor.HetznerMetricsRefresher.refreshCloud(): 60 calls/hour per cloud is load-bearing for the dashboard freshness contract; gating on breakers would mask real outages with stale gauges.HungBuildDetector: does not call the Hetzner API.DcHealthTracker.sortByHealth: unchanged contract (sort, do not filter); useful for diagnostic visibility and pinned by existing tests.
- New
DcCircuitBreakerTest.halfOpenIsSingleProbe,halfOpenLeaseRearmsAfterReopening,getStateLazyResetArmsProbeLeasefor the HALF_OPEN probe-lease invariants. - New
DcHealthTrackerTest.filterHealthy{DropsOpenBreakers,AllUnhealthyReturnsEmpty,IsArchScoped,HandlesUnknownArch,HandlesEdgeCases}. - New
HetznerCloudSimpleTest.provisionSkipsWhenAllMatchingDcsUnhealthy(storm-gate regression: asserts ZEROfetchAllServers/createServercalls when all matching breakers are OPEN, plusPROVISION_SKIPPED{reason="no_healthy_dc"}== 1, plusPROVISIONING_PENDINGstays at 0). - New
HetznerCloudSimpleTest.provisionProceedsWhenOneHealthyDcExists(gate is not over-zealous: one healthy DC keeps provisioning live). - New
NodeCallableRetryTest.breakerOpenSkipsTemplate,breakerOpenAllSkippedThrowsIllegalState,breakerOpenSkipRespectsFailoverCompat. - Updated
HetznerMetricProviderTest.provisionOutcomesEnumerationIsExhaustiveto includedc_breaker_open(size 11 -> 12). - New
HetznerMetricProviderTest.provisionSkippedReasonsEnumerationIsExhaustivepinning the 5 REASON_* constants. HetznerCloudSimpleTest+NodeCallableRetryTestnow resetHetznerMetricProviderin@BeforeEach/@AfterEachso the new counter assertions cannot flake.
- No persistence-schema changes (
$JENKINS_HOME/hetzner-dc-health.xmlformat unchanged). Rolling back to v25 is mechanically clean; Mimir may retain v26 label series briefly (no_healthy_dc,dc_breaker_open) but no series is removed. - Wire-format
PROVISION_SKIPPEDreason strings are unchanged (cap_reached,rate_limited, etc.); only the call-site spelling swapped from inline literals to constants. - Backward compatible with v25 dashboards. Adds two new label values that will appear naturally during the next forced-open canary test on ps3.
Two related fixes that ship together so v25 is the canonical post-incident plugin across the fleet.
XStream's Unsafe.allocateInstance bypasses field initializers on
deserialized clouds (plugin dynamic reload, fresh-EBS rebuild). The
pendingProvisions AtomicInteger and seenArchExtras set could be null
on the first provisioning / refresh tick, NPE'ing the cap accounting and
the lazy-arch path. v25 adds ensurePendingProvisions() /
ensureSeenArchExtras() lazy guards plus a readResolve() re-init pass
(belt-and-suspenders).
DcCircuitBreaker and DcHealthTracker keys widened from a single
location string to a composite <location>:<arch> so an ARM-only
capacity event does not poison the AMD64 breaker for the same DC. Legacy
XML (pre-v25 single-location format) is migrated on first load: each
legacy entry is cloned into one breaker per arch in
ALWAYS_EMIT_ARCHS. New counter
hetzner_dc_health_legacy_keys_migrated_total{location, arch} makes the
migration auditable.
- New
DcHealthPersistenceTestcovers the legacy-key migration path end-to-end. DcHealthTrackerTest.archIndependenceWithinSameDcpins the invariant that ARM failures in fsn1 do not trip the AMD64 breaker for fsn1.HetznerCloudRehydrateTest.{readResolveRehydratesBothTransientFields, readResolveIsIdempotent, ensurePendingProvisionsLazyInitialisesOnAccess}cover the Part A guards.
- One-shot migration on first load: each legacy breaker entry produces
N new entries (one per arch in
ALWAYS_EMIT_ARCHS). Subsequent loads no-op. - Backward compatible with v23/v24 dashboards. Series with
arch=arm64(orarm64+unknownafter v24) are now distinct from the AMD64 versions; aggregations viasum by(...)are unchanged.
Trims the arch="unknown" series so it only appears when a non-canonical Hetzner SKU is actually observed, rather than being emitted as a constant 0 on every refresh. v23 deployed to ps3 showed hetzner_running_servers{arch="unknown"} 0 permanently, which is background noise and a false signal for any alert that watches unknown > 0.
HetznerMetricProvider.ALWAYS_EMIT_ARCHS(new) replacesKNOWN_ARCHSas the iteration target inHetznerCloud.runningNodeCount(). Holds onlyamd64andarm64so those two get explicit zero-emission on every pass.KNOWN_ARCHSis kept as the full three-value catalogue for test/reset helpers.HetznerCloudnow tracks a per-instanceseenArchExtrasset (ConcurrentHashMap.newKeySet()). On each refresh, any non-canonical arch observed with a non-zero count is promoted into the set; subsequent passes keep emitting that arch (zero or otherwise) so a recent-incident marker persists for the JVM's lifetime. Cleared at restart, which is the right cadence (a strange-SKU incident weeks ago is not load-bearing if the fleet has been clean since).
HetznerMetricsRefresherTest.refreshMetrics_splitsByArchnow asserts thatarch="unknown"is absent from the registry when no unknown SKU has been observed (getSampleValuereturns null), rather than asserting0.- New
refreshMetrics_lazilyEmitsUnknownArchOnceObservedwalks the three-pass lifecycle: clean → unknown observed → unknown gone (series persists at 0). - New
alwaysEmitArchsExcludesUnknownpin inHetznerMetricProviderTest.
- Backward compatible with v23 dashboards: aggregations across
archare unchanged. Panels that explicitly selectarch="unknown"will getno datauntil an unknown SKU appears, which is the desired behavior.
Adds an arch label (amd64 / arm64 / unknown) to the two Hetzner-side metrics that carry per-server context, so the Grafana dashboard can split worker activity by CPU architecture. The Hetzner serverType naming convention is unambiguous: cpx* / cx* / ccx* are AMD64, cax* is ARM64. Everything else collapses to unknown so cardinality stays bounded if Hetzner adds a new SKU prefix.
HetznerMetricProvider.archOf(String serverType)public helper plus theARCH_AMD64/ARCH_ARM64/ARCH_UNKNOWNconstants and aKNOWN_ARCHSlist. Used by call sites that need to clear stale series (set 0 for archs that drop to zero so the gauge does not pin at its last non-zero value in Mimir).
hetzner_running_serversgains anarchlabel.HetznerCloud.runningNodeCount()groups VMs by arch fromServerDetail.getServerType().getName(), emits one row per arch inKNOWN_ARCHS, and explicitly sets 0 for archs with no running servers in the current pass.hetzner_orphan_servers_reaped_totalgains anarchlabel.OrphanedNodesCleaner.terminateOrphanedServerderives the arch from the doomed server'sserverTypeat reap time.
hetzner_provisioning_pendingstays cloud-only. Its backing state is a singleAtomicIntegerper cloud (HetznerCloud.pendingProvisions); splitting it by arch would require refactoring every provision-lifecycle code path (provision(),provisionCompleted(),NodeCallable) and threading an arch through the completion path. Tracked for a follow-up.hetzner_instance_capstays cloud-only. The cap is a single configured value per cloud with no per-arch decomposition; emitting it ascloud="x",arch="amd64"=50,cloud="x",arch="arm64"=50would be semantically wrong (a sum would double the real cap).
- Dashboards using
sum by(cloud)orsum without(arch)over the relabelled metrics keep working unchanged; the arch dimension collapses cleanly at query time. - Dashboards using raw selectors (
hetzner_running_servers{cloud="..."}) will now match multiple series (one per arch). The Percona dashboard atresources/addons/grafana/dashboards/hetzner-plugin.jsonalready aggregates viasum by(master)so no panel breakage is expected. - Mimir cardinality impact: 10 masters × 3 archs (effective 2) for the two metrics = ~20-30 extra active series. Negligible against the baseline.
Phase 4b of the ps3 canary resilience plan (PS-11173). After a master JVM restart, the plugin now re-adopts the master's own Hetzner VMs as Jenkins agents via Hetzner label selector, instead of letting OrphanedNodesCleaner reap them. This closes the failure class where every in-flight build was lost on a kill -9 / spot interrupt even though the Hetzner VMs themselves had survived. ps3-only canary; default-off on every other master.
HetznerWorkerRehydrator.rehydrate()runs as@Initializer(after=JOB_LOADED, before=COMPLETED). For each configuredHetznerCloud, it enumerates VMs viafetchAllServers(cloudName)(selector:jenkins.io/managed-by=hetzner-jenkins-plugin,jenkins.io/cloud-name=<cloud>), matches each VM to aHetznerServerTemplate, andJenkins.addNodes a reconstructedHetznerServerAgent. Per-VM errors are caught and counted; the pass never bubbles to the boot sequence. Single INFO summary per cloud:rehydrated N/M VMs (skipped_existing=..., no_match=..., ambiguous=..., other=...).HetznerConstants.LABEL_TEMPLATE_NAME = "jenkins.io/template-name". Written at provision time so the rehydrator can match a VM to its template with one exact lookup instead of falling back to the heuristic.- Heuristic fallback for VMs provisioned before v103.percona.22 (no
template-namelabel): match by Hetzner serverType + datacenter/location + name prefix. Ambiguous matches log WARN, incrementrehydrate_failures_total{reason=ambiguous}, and skip the VM. - Idempotency guard: skip VMs whose name already has a
Jenkins.get().getNode(...)entry, so a re-entrant call cannot double-add.
HetznerCloudResourceManager.createLabelsForServeraccepts an optionaltemplateName; the new label lands on VMs provisioned in v103.percona.22+.fetchAllServerskeeps the existing two-label selector (cloud-name only) so the same query matches both legacy and v103.percona.22+ VMs.ControllerListener.onOnlinedefersOrphanedNodesCleaner.doCleanup()byhetzner.rehydrate.grace-period-minutes(default 5) when-Dhetzner.rehydrate.enabled=true. Otherwise the cleanup runs immediately as before. The defer gives the rehydrate pass time to re-adopt VMs before they are evaluated against the empty Jenkins-node list.
-Dhetzner.rehydrate.enabled=trueto enable both the rehydrate@Initializerand the controller-online cleanup defer. Defaultfalse; ps3 master JVM args are the only opt-in for the canary.-Dhetzner.rehydrate.grace-period-minutes=<n>to override the 5-minute defer (consulted only when the feature is on).
- Fresh upgrade from v103.percona.21: no migration. Without the flag the new code paths are dormant.
- Downgrade to v103.percona.21: VMs provisioned in v103.percona.22+ keep the
template-namelabel, which prior versions ignore. Safe rollback.
Three independent reviews (2 opus subagents + codex gpt-5.5 xhigh) converged: Phase 4b does not need DynamoDB. The jenkins.io/cloud-name=<master> label is a hard partition; each master rehydrates only its own VMs. Every piece of state needed at rehydrate is recoverable locally (Hetzner labels + Jenkins credentials on persistent EBS + in-memory template list). PS-11177 remains the home for any future cross-master shared state.
HetznerWorkerRehydratorTestcovering the matching logic: exact label match, heuristic by serverType + location + prefix, datacenter-vs-location forms, ambiguous twins, prefix disambiguation, no-match, stale-label fallthrough, empty/null template list. End-to-end validation lives on ps3 (kill -9 + boot log + node count + counters).
hetzner_rehydrated_workers{cloud}(gauge) - agents rehydrated on the most recent pass.hetzner_rehydrate_attempts_total{cloud}(counter) - VMs evaluated.hetzner_rehydrate_successes_total{cloud,template}(counter).hetzner_rehydrate_failures_total{cloud,reason}(counter) - reason in {no_match,ambiguous,name_collision,add_node,other}.
Phase 4a of the ps3 canary resilience plan (PS-11173). DC circuit-breaker state now survives a Jenkins JVM restart, which closes the failure class where a fresh master after spot interrupt would re-attempt every breaker as CLOSED and stampede a still-sick DC.
DcHealthTracker.load()reads$JENKINS_HOME/hetzner-dc-health.xmlat@Initializer(after=PLUGINS_STARTED)and rehydrates the in-memoryBREAKERSmap. Missing file is a silent no-op so a clean install or a downgrade to v103.percona.20 is unaffected.DcHealthTracker.save()schedules a write viajenkins.util.Timer.get()wheneverrecordFailureorrecordSuccessmutates state. AnAtomicBooleancoalesces concurrent triggers so a burst produces a single write. Deferring toTimerkeeps disk I/O off thesynchronizedbreaker lock.DcCircuitBreaker.afterLoad(fallbackLocation, now, staleOpenTtlMs)runs on every breaker after deserialization to restore Prometheus gauges and apply a 30-min TTL: anOPENbreaker whoseopenedAtis older than 30 min loads asCLOSEDwith zero consecutive failures, so a transient incident from before the restart does not pin a DC out of rotation.@XStreamAlias("dcCircuitBreaker")onDcCircuitBreakerfor a stable XML schema even if the class is later moved across packages.
DcCircuitBreaker.locationis no longerfinal. XStream deserialization assigns it directly, andafterLoad()can fall back to the persisted map key if older XML omits the field. The@Getterand allsynchronizedaccessors are unchanged.
- Fresh upgrade from v103.percona.20: no migration needed; the XML file is created on first
recordFailure/recordSuccessafter upgrade. - Downgrade to v103.percona.20: the leftover
hetzner-dc-health.xmlis ignored (the prior version never reads it). Safe rollback path. - Persistence skips silently when
Jenkins.getInstanceOrNull()returnsnullso existing unit tests that mockJenkins.get()without stubbinggetRootDir()continue to pass without touching the persistence layer.
DcHealthPersistenceTestcoveringsavesOnFailure,loadsOnInit,ttl_resetsStaleOpen,missingFile_isNoOp.
hetzner_dc_health_loaded_breakers(gauge) - number of breakers restored on the most recentDcHealthTracker.load()call.hetzner_dc_health_saves_total(counter) - successful XmlFile writes.hetzner_dc_health_save_failures_total(counter) - failed writes; non-zero rate is operationally actionable.hetzner_dc_health_stale_open_resets_total{location}(counter) - breakers reset from OPEN to CLOSED on load due to the 30-min stale-OPEN TTL.
Deduplicate server entries from paged Hetzner API responses.
HetznerCloudResourceManager.fetchAllServers()deduplicates servers by ID across pages. The upstream API can return the same server in multiple pages during concurrent list + modify operations; duplicate entries inflatedhetzner_running_serversand causedOrphanedNodesCleanerto attempt double-termination of the same VM.
Extract Run from PlaceholderExecutable for Pipeline hung-build detection.
HungBuildDetectornow unwrapsPlaceholderExecutable(the Jenkins Pipeline step executor wrapper) to reach the underlyingRun.isBuilding()check. Without this, Pipeline builds were invisible to the detector and stuck Pipeline builds could run indefinitely without incrementinghetzner_stuck_builds_total.
Codex review follow-up on v103.percona.17. The new HungBuildDetector and its three metrics shipped to ps3.cd as a canary; the review surfaced two correctness blockers and one observability fault that would have made fleet rollout silently misleading. v18 fixes all four before promoting beyond ps3.
HungBuildDetector.scan()is nowsynchronized(this). The JenkinsPeriodicWorkscheduler does not contractually forbid overlapping ticks if a single tick runs longer than the recurrence period (e.g. an executor walk that hangs on a stuck node lookup). The v17 implementation mutated theseenLinkedHashMap and calledSTUCK_BUILDS_TOTAL.inc()without a lock; two overlapping ticks could double-count a single hung build. Wrapping the wholescan()body insynchronized(this)keeps the dedup contract honest.hetzner_oldest_build_age_secondsnow clears stale template children at the end of every tick. v17 populatedtemplatesObservedbut never used it; once a hung build finished, the gauge stayed pinned at the last observed peak forever, scaring operators into investigating builds that had already completed. NewclearStaleAgeChildren()reads the family's existing samples via the simpleclientcollect()API, and callsGauge.remove(template)on any child whosetemplatelabel is not intemplatesObservedfor the current tick.
- Dropped the in-plugin
masterlabel fromhetzner_stuck_builds_total,hetzner_oldest_build_age_seconds, and the renamedhetzner_jenkins_real_busy_executorsgauge. Per ADR 0013 in thepercona-ci-platformrepo, master-side Grafana Alloy injectsmaster="<inst>.cd"via relabel config on the push pipeline, so the plugin-side value was redundant; worse, Alloy'sexternal_labelsonly fill MISSING labels, not existing empty ones, so the v17 fallback (empty string whenJenkinsLocationConfiguration.getUrl()was unset) emitted series dashboards filteringmaster=~".+\\.cd"would silently drop. RemovedHetznerMetricProvider.masterLabel()and the helperderiveMasterFromUrl()since neither has any other caller. All v16-and-earlierhetzner_*metrics already shipped without a plugin-emitted master label; v18 aligns the v17 newcomers with that convention. - Renamed
hetzner_executor_busy_realtohetzner_jenkins_real_busy_executors. The original name broke thehetzner_<domain>_<thing>convention used by every other Jenkins-side metric in the plugin; the rename keeps the family namespace clean and groups the gauge alongside otherhetzner_jenkins_*metrics. No live Grafana panel referenced the v17 name yet (panels 210/211 usehetzner_stuck_builds_totalandhetzner_oldest_build_age_seconds), so this is a safe rename.
HungBuildDetectorTest.concurrent_scan_does_not_double_count-- spawns two threads racing onscan()with the same stuck build; assertsSTUCK_BUILDS_TOTALincrements exactly once. Pins the v18 blocker-1 fix.HungBuildDetectorTest.oldest_age_gauge_clears_when_build_finishes-- tick 1 has a hung build onunknown; tick 2 has no hung builds; asserts the gauge family contains NO samples forunknownafter tick 2. Pins the v18 blocker-2 fix.HungBuildDetectorTest.oldest_age_gauge_clears_when_template_no_longer_observed-- variant where a stale child forold-tplis injected; the next tick must remove it while keeping the current template's child intact.HungBuildDetectorTest.ttl_expiry_re_arms_dedup-- injects a fake clock, advances 8 days past the 7-day TTL, and asserts the same hung build re-arms the counter (otherwise a build hung for >7 days would silently stop counting).
- Any Grafana queries or alerts referencing the
masterlabel onhetzner_stuck_builds_total,hetzner_oldest_build_age_seconds, orhetzner_executor_busy_realmust drop themaster=filter. Alloy adds the samemaster="<inst>.cd"label downstream via relabel config on the push pipeline, so any external query that previously matched on the in-plugin label keeps working at the Mimir side once that filter is removed (Alloy's value is identical to what the in-plugin helper would have produced whenJenkinsLocationConfiguration.getUrl()was set). - Grafana queries or alerts referencing
hetzner_executor_busy_realmust be updated tohetzner_jenkins_real_busy_executors. Within Percona's repos there are no live references to the v17 name; the rename is a pre-emptive cleanup.
Detect long-running ("hung") builds where Run.isBuilding() keeps returning true for days, and emit Prometheus metrics so Mimir surfaces the condition before it accretes into a fleet-wide outage.
- New
HungBuildDetector(PeriodicWork, period: 10 minutes by default) iterates the controller's executors on every tick, identifies still-building runs older than the configured threshold (24h by default), and increments a dedup-cached counter for each unique build. Without this, the v103.percona.16 + CLIexecutors --realpipeline trustedRun.isBuilding()to filter zombie executors; production traffic on rel.cd and pmm.cd revealed runs reportingisBuilding=truefor 3.9 to 6.4 days, stalling the idle-master plugin-upgrade cron for 6+ hours on 2026-05-15/16. - A new gauge
hetzner_executor_busy_real{master}mirrors the CLI's--realcount plugin-side, so readiness/is-idle probes can hit/hetzner-prometheusdirectly instead of round-tripping a Groovy snippet through Script Console for every check.
- The CLI's
executors --realflag (shipped 2026-05-16) is the load-bearing filter for the idle-master cron. TrustingRun.isBuilding()alone is insufficient: layering an independent age threshold on top is necessary to distinguish real work from corrupted run state. The plugin owns the data Jenkins doesn't honestly report through its own APIs.
hetzner_stuck_builds_total{master, template, threshold_hours}-- Counter. Incremented exactly once per (build, threshold) when the build first crosses the threshold; a 7-day dedup TTL covers the longest plausible stuck-build lifetime before manual intervention. Useincrease()/rate()over the total since a JVM restart drops the dedup cache.hetzner_oldest_build_age_seconds{master, template}-- Gauge. Set on every tick to the max elapsed time among still-building runs grouped by Hetzner template. Templates without in-flight builds at the tick boundary are absent from the series for that emit cycle.hetzner_executor_busy_real{master}-- Gauge. Count of executors whose currently-executingQueue.Executableis aRunwithisBuilding=true.
hetzner.hung-build.threshold-hours(default24) -- a build older than this is classified hung. Honored fresh on each tick.hetzner.hung-build.poll-period-minutes(default10) -- detector recurrence period. Read by Jenkins scheduler on each reschedule.hetzner.master.label(operator override) -- themasterlabel value emitted on the new metrics. Falls back to the leading hostname ofJenkinsLocationConfiguration.getUrl()with a.cdsuffix (e.g.rel.cd), then empty (Alloy'sexternal_labelson the push pipeline adds the value downstream).
- Iterates
Jenkins.getComputers()[].getExecutors()rather thanJenkins.getAllItems(Run.class): the executor-walking path is bounded by the executor count (typically <100 per master) and avoids touching cold historical run state. templateOf(Computer)walkscomputer.getNode()and casts toHetznerServerAgentto recover the template name. Non-Hetzner agents (built-in controller, EC2, manually-added nodes) and post-restart deserialized agents (template is transient) reporttemplate="unknown"; dashboards should filtertemplate!="unknown"on per-template panels.- A threshold tweak via system property re-arms the counter at the new boundary (the dedup key includes
thresholdHours), but does NOT reset the existing dedup cache. A build that already crossed 24h does not re-arm if the threshold drops to 12h. Restart the JVM (or wait 7 days for cache expiry) to re-arm such builds explicitly. - Defensive
try/catch(Throwable)indoRun()mirrorsHetznerMetricsRefresher/OrphanedNodesCleaner: a Jenkins-core or Hetzner-API exception cannot kill the timer.
Periodically refresh hetzner_running_servers and hetzner_provisioning_pending regardless of provisioning activity.
hetzner_running_serversno longer pins to the last-known value when a cloud is idle. NewHetznerMetricsRefresher(PeriodicWork, period: 1 minute) walks every configuredHetznerCloudand calls a newpublic HetznerCloud.refreshMetrics()method, which re-queries the Hetzner API for the live server count and re-emits the in-memory pending counter. Observed before the fix: psmdb.cd reportedhetzner_running_servers=25in Mimir while the Hetzner API showed only 2 running servers; the cloud had drained from a recent peak but no new provision attempts triggered a gauge update, so the metric was stuck for hours.- The new
refreshMetrics()method is also called defensively againstPROVISIONING_PENDINGfrom the in-memorypendingProvisionsAtomicInteger, so a forgottenset(...)after an atomic mutation does not desync the gauge.
- Field deployment of
v103.percona.15to the 10-master Percona Jenkins fleet exposed the discrepancy when validating the new dashboard panel 200 ("Active workers per master") at 2026-05-15 09:30 UTC. The new panel would show 25/100 utilisation for psmdb when the truth was 2/100, making the dashboard misleading.
- Per-cloud refresh consumes one Hetzner API call per minute (60/hour). Token budget is 3600/hour, so even 15 templates across 10 masters share the cost comfortably. The refresher skips refresh if the credentials' rate-limiter is open, same pattern as
OrphanedNodesCleaner.
Surface the actual running Jenkins core version on hetzner_plugin_info.
hetzner_plugin_infometric labeljenkins_baselinerenamed tojenkins_version. The label value was previously the hard-coded string"2.479"(matching<jenkins.baseline>inpom.xmlonly by convention); it now reads fromJenkins.getVersion().toString()at class init, so it reports the real running core version on each master (e.g.2.528.3,2.541.3). Fully guarded with try/catch; value is"unknown"if the call throws or returns null.
- Field deployment of
103.percona.14to the 10-master Percona Jenkins fleet exposed that every master emittedjenkins_baseline="2.479"regardless of its actual running core version, which made the label useless for dashboards intending to break out per-core-version behaviour.
- Any Grafana queries or alerts referencing the old
jenkins_baselinelabel must be updated tojenkins_version. Within Percona's repos this is only the panel-104 transformrenameByNameinpercona-ci-platform/resources/addons/grafana/dashboards/hetzner-plugin.json.
Wait for cloud-init to finish before launching the remoting JVM.
HetznerServerComputerLaunchergenerates an.agent.start.shthat callscloud-init status --wait(when present) and verifiesjavaexists on PATH beforeexec'ing the remoting JVM. Previously the script ranjava -jar remoting.jarimmediately; on stock images (e.g. Hetzner debian-12) where the user-data script installs openjdk via apt during cloud-init'smodules-finalstage, java was not yet on PATH and the channel EOFed instantly. Symptom: chronicjava.io.EOFException: unexpected stream terminationatHetznerServerComputerLauncher.launchAgentwith a highbootstrap_iorate (verified on pg.cd and psmdb.cd; cpx62 reproduction showed cloud-initmodules-finaltaking ~49 seconds during whichwhich javareturned not-found).- The script logs to stderr only (stdout is the remoting channel) and exits with a clear nonzero code on cloud-init failure or missing java, instead of letting the EOF surface as an opaque bootstrap failure.
- Controller-side
logger.infoline inlaunchAgent()now states that the launch script waits for cloud-init, so the Jenkins log shows expected behavior even when remote stderr is not wired through.
- Pre-baked images (no
cloud-initon PATH, java already installed) skip the wait and fall straight through. The aarch64 snapshot path is unaffected. - Outer bound is unchanged:
NodeCallable.doProvisionstill wrapscomputer.connect(false)in a Future bounded by the template'sbootDeadlineminutes. - A long-term follow-up is to pre-bake a Hetzner snapshot with java/docker/awscli installed; that is orthogonal to this fix and will reduce provisioning latency by the cloud-init
modules-finaltime on top of fixing the race.
Justfile pin bump.
- Bumped justfile
versionpin to103.percona.13. No code changes.
NodeCallable hardening and metrics endpoint follow-ups. Documented intermediate with no standalone git tag; these changes shipped under the v103.percona.13 release.
NodeCallable: loopback IP gate prevents connecting to a server whose address resolves to 127.x.- DC health gate added at
NodeCallable.call()entry (belt-and-suspenders alongside theprovision()pre-check from v3). - Provisioning outcome enum extended; metrics endpoint loopback follow-ups from v9/v10 review.
Make /hetzner-prometheus an UnprotectedRootAction so anonymous loopback callers are not blocked by Jenkins core authorization.
HetznerPrometheusEndpointnowimplements UnprotectedRootAction(instead ofRootAction). v103.percona.10 dropped theJenkins.SYSTEM_READcheck insidedoIndex(), but the request was still rejected with HTTP 403 byGlobalMatrixAuthorizationStrategybefore ever reachingdoIndex(). Verified anonymouscurl http://127.0.0.1:8080/hetzner-prometheusreturned 403 on ps3.cd with v103.percona.10 active.- Javadoc updated to reflect that the trust boundary is the Jenkins 8080 loopback bind, not core ACLs.
- Required for ADR 0013's master-side Alloy push model (PS-10997 Phase 2). The Alloy systemd unit on each EC2 master scrapes the endpoint with no credentials; auth lives at the in-cluster
alloy-gatewayinstead.
Drop SYSTEM_READ permission gate on /hetzner-prometheus for the push-model rollout.
HetznerPrometheusEndpoint.doIndex()no longer callsJenkins.get().checkPermission(Jenkins.SYSTEM_READ). The endpoint is unreachable from external clients (Jenkins binds 8080 to 127.0.0.1 on Percona masters); the master-side Grafana Alloy systemd unit is the only consumer. Auth lives at the in-clusteralloy-gateway(NGINX bearer-token sidecar + ALB inbound-CIDRs allowlist), not at the Jenkins endpoint.- Javadoc updated to reflect the localhost-only contract and reference ADR 0013 / PS-10997 Phase 2.
- Companion repo:
nogueiraanderson/percona-ci-platform(alloy-gateway addon). - Supersedes the prior plan that ran a
prom-scraper-svcJenkins user with API-token basic auth for in-cluster Prometheus to scrape this endpoint over public DNS.
Self-contained /hetzner-prometheus Stapler endpoint (PS-10997 Phase 1).
HetznerPrometheusEndpoint(RootActionat/hetzner-prometheus): exposes 40+hetzner_*metric families (DC circuit breaker state, provisioning latency, API rate-limit headroom, CRW iterations, template suppression, anomaly counters) in Prometheus 0.0.4 text format.io.prometheus:simpleclientbundled directly in the plugin, no dependency on the communityprometheus-plugin(fleet audit: 0/10 masters had it installed).HetznerMetricProvider: registers allhetzner_*collectors againstCollectorRegistry.defaultRegistry.
Initial Prometheus metrics scaffolding (PS-10997).
- Initial
hetzner_*metric definitions andHetznerMetricProviderscaffolding: exposes plugin state (provisioning counts, rate-limit headroom, DC breaker state) via Prometheus gauges/counters. Superseded by the self-contained endpoint in v9.
SSH retry backoff, log level fixes, and robustness cleanups.
- SSH launcher: retry backoff for transient connection failures during agent launch, reducing spurious bootstrap failures on VMs with slow SSH startup.
- Noisy log entries lowered to DEBUG; improved context in error messages across the provisioning path.
- Miscellaneous null guards and defensive catches in the boot and teardown paths.
Rate-limit code review follow-ups.
- Addressed Codex static analysis findings on
HetznerApiClientandRateLimitInterceptor(v4): missing null checks, rate-limit state transition clarity, exception message alignment with the v1 convention.
Retry infrastructure and template error suppression.
RetryInterceptor: OkHttp interceptor with exponential backoff and jitter for transient errors (429, 502, 504, socket timeouts). Max 3 retries, honorsRetry-Afterheader.TemplateErrorTracker: suppresses templates with persistent config errors (e.g., deprecated image IDs). 3-failure threshold, 30-minute suppression, half-open probe on expiry.- Config error detection (
invalid_input) aborts DC failover immediately. - Rate-limit (429) during DC failover aborts the entire provisioning attempt (token-scoped).
- Boot status polling skips API calls when rate-limited.
- Improved log coverage, context, and consistency across rate-limit paths.
Rate-limit infrastructure and API caching.
HetznerApiClient: per-token API client wrapper with rate-limit state tracking, lazy auth viaAuthInterceptor, and HTTP 401 client invalidation for token rotation support.RateLimitInterceptor: OkHttp interceptor parsingRateLimit-Limit/Remaining/Resetheaders from every API response.- Guava response caches: SSH keys (30min TTL), label IDs (15min TTL), server lists (30sec TTL) with
recordStats(). checkRateLimit()guard before API-intensive operations (createServer,getOrCreateSshKey,fetchAllServers).HetznerCloudResourceManager.getCacheStats()for Script Console observability.OrphanedNodesCleanerskips cleanup cycle when rate-limited.
destroyServer()catchesException(not justIOException), hardening the CRW timer death fix for all exception types.
Per-datacenter circuit breaker failover.
DcCircuitBreaker: per-DC state machine (CLOSED / OPEN / HALF_OPEN). 2-failure threshold trips breaker for 5 minutes, auto-reset to HALF_OPEN for single probe.DcHealthTracker: static registry of breakers per location withsortByHealth()for template ranking. Shuffles within health partitions for load distribution.HetznerProvisioningException: typed RuntimeException carrying HTTP status, Hetzner error code, and DC location. Methods:isRateLimited(),isConfigError(),isResourceUnavailable().HetznerCloud.rankTemplatesByHealth()replaces randompickTemplate().NodeCallablerewritten with DC failover loop: iterates ranked templates, records success/failure inDcHealthTracker, cleans up leaked servers on bootstrap failure.- CLI observability via
jenkins hetznercommands (health,status,nodes,servers,templates,version,orphans,reset,trip). dc-health-check.groovyfor Script Console inspection.
Helper.assertValidResponse()throwsHetznerProvisioningExceptionon error responses (typed instead of genericIllegalStateException).
Architecture validation, null-safety, and deployment tooling.
- Architecture validation via
NodeCallable.inferArchFromServerType()(CAX* = arm64, else x86_64) and post-bootuname -mcheck viaUnameCallable. - Deploy (
scripts/deploy.sh), check (scripts/check.sh), and verify (scripts/verify.sh) scripts. justfilerecipes:deploy,deploy-all,check,verify.
- Launcher null-safety across
DefaultConnectionMethod,DefaultV6ConnectionMethod,PublicAddressOnly,PublicV6AddressOnlywith descriptive error messages instead of NPE. AbstractByLabelSelector:.findFirst().get()replaced with.findFirst().orElseThrow()with descriptive error including selector and location.OrphanedNodesCleaner: removedsetTemporarilyOffline()call for ghost nodes, added race guard.
Retention bug fixes for idle VM accumulation.
- CRW timer death (critical):
destroyServer()wrappedIOExceptionin uncheckedIllegalStateException, killing theComputerRetentionWorkperiodic timer permanently. Changed to log-and-return. Confirmed: CRW was dead for 28 hours on two production instances. This fix was later contributed back to upstream and ships in jenkinsci/hetzner-cloud-plugin as v106 (commit796d19b). - One-directional orphan cleanup:
OrphanedNodesCleanernow removes both VMs without Jenkins nodes AND Jenkins nodes without VMs (ghost nodes). Per-item try-catch prevents one failure from blocking cleanup of remaining items. - Null transient fields after restart:
cloud,template,serverInstanceare transient and null after deserialization. Added null guards in_terminate(),isAlive(),getDisplayName(). HetznerCloudResourceManager.refreshServerInfo()throwsIOException(checked) instead ofIllegalStateException.Helper.assertValidResponse()null body guard.