Skip to content

fix: port upstream controller safety and reconciliation improvements - #62

Merged
pando85 merged 44 commits into
masterfrom
upstream/safety-and-reconciliation-2026-10
Oct 13, 2026
Merged

pando85 merged 44 commits into
masterfrom
upstream/safety-and-reconciliation-2026-10

Conversation

@pando85

@pando85 pando85 commented Oct 13, 2026 •

Copy link
Copy Markdown

Summary

Single integration PR preserving the original upstream commits while adapting functionality to Forkline. The upstream merge keeps Forkline master (b1792f4) as its first parent and Treetscom upstream master (7268ba2) as its second; upstream authors and SHAs remain reachable.

What is included

  • Ownership and deletion safety: immutable Service UID label is the sole ownership criterion; no guessing from a mutable name or shared node IPs; cleanup works after deletion/type/class changes even with invalid annotations. An unowned name collision is rejected.
  • Traffic routing: honor externalTrafficPolicy; selectorless Local Services use ready EndpointSlices; selector-based Local Services retain simple pod-placement target selection, leaving target health to Hetzner TCP checks.
  • Capacity safety: preflight Hetzner's advertised target and listener limits. Oversized configurations and unsafe downgrades fail before mutating an existing LB; never silently truncate targets or shed healthy existing targets.
  • Visible failures: emit Kubernetes Warning Events for invalid configuration, count reconciliation failures, and support the existing optional PrometheusRule alerts.
  • API reliability: share exponential pause on 429; preserve typed errors for load balancer type lookup; retry transient Hetzner action failures and handle already-defined targets.
  • Kubernetes status: compare typed LoadBalancerIngress values and avoid redundant status writes; remove the unsupported custom dns field.
  • Finalizers, validation and tests: safe concurrent finalizer handling, exact Hetzner name matching, no new LB without an allocated TCP NodePort, regression tests and cargo test --all-targets --locked in PR CI.

Explicit decisions

  • No automatic legacy adoption. Pre-upgrade, unlabelled Hetzner LBs are not managed or deleted. If retaining their IP is essential, verify ownership and manually apply the Service UID label. Otherwise new LBs are named <service>.<namespace>.
  • No silent target truncation. If LB capacity cannot accommodate desired nodes or listeners, change the LB type or target selection; the controller reports an error and leaves the existing LB unchanged.
  • No automatic downsizing remediation. The operator will not remove healthy targets to make an invalid downgrade possible.
  • No expansion of Local endpoint discovery for selector-based Services. Hetzner's TCP health checks determine target health; this keeps discovery simple.

Not included

Full desired-state fingerprint memoization, Node-change-driven watches, complete token redaction/Event parity from upstream #37, optional per-cluster prefixes, lockfile dependency refresh and dedicated transient deletion retries remain separate follow-ups.

Rollout and validation

The existing Helm chart has opt-in Prometheus alert rules (metrics.prometheusRules.enabled); Kubernetes Events alone do not send notifications. A production alert also requires a configured Prometheus/Alertmanager pipeline.

Validate the latest Actions run before merging. Deploy first against disposable Hetzner load balancers, checking unchanged status patches, ambiguous ownership/name collisions, oversized capacity and invalid type downgrades. Existing unlabelled balancers require a separate audit if their IPs or continued billing matter.

sircthulhu and others added 22 commits April 23, 2026 13:15
Signed-off-by: Dev Jadeja <dev@treets.com>
…olicy (#31)

* fix(nodes): select target nodes by externalTrafficPolicy

Target nodes were always restricted to the nodes running the service's
pods, which is the behaviour of externalTrafficPolicy: Local. Services
using the default Cluster policy lost every node that kube-proxy could
have forwarded from, so a service whose only pod sits on a node where
its nodePort is unreachable ends up with no healthy target at all.

Follow the policy declared by the service instead, and cover the port
mapping and the policy check with unit tests.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* fix(targets): keep reconciling after a rejected target

Hetzner refuses IP targets outside the vSwitch subnet of the attached
network. Such a rejection aborted the whole reconciliation, so a single
node the API declines kept every other node out of the load balancer.
Log the rejected target and carry on with the rest.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* docs(readme): describe node selection and the nodePort requirement

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* fix(nodes): keep excluded and cordoned nodes out of the balancer

Following externalTrafficPolicy made every node a target under the
Cluster policy, including nodes a cluster marks as unfit for external
load balancers: kubeadm labels control-plane nodes with
node.kubernetes.io/exclude-from-external-load-balancers, and a cordoned
node is draining. Filter both out.

A service left without a single exposable port no longer gets an
external IP patched onto its status, so a load balancer that forwards
nothing is not reported as ready. Port mapping now carries the existing
LBService type instead of a bare pair of integers.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* fix(targets): fail reconciliation when no target can be added

Tolerating a rejected target also hid the case where every target is
rejected: reconciliation reported success and the service was patched
with an external IP for a load balancer that had nowhere to forward to.
Fail instead when no target could be added at all, so the controller
requeues and the service stays unready.

Warn as well when more nodes are selected than the balancer type holds,
using the limit the API reports for that type.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* ci(pre-commit): run cargo test

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* docs(readme): note the node exclusions, target limits and upgrade impact

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* fix(targets): plan targets instead of failing on every rejected one

Counting the attempts of a single run treated a permanently rejected
node as a total failure: once the acceptable targets were in place,
every following run retried only the rejected ones and reported failure
forever. Count the targets that are actually live instead, and carry the
reason of the last rejection into the error.

Targets are now planned up front: deduplicated, sorted, and trimmed to
what the balancer type holds, so a cluster larger than the limit stops
retrying doomed calls and keeps a stable set of targets. The limit is
not reported while a type change is still in flight, when the balancer
answers with the type it is leaving.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* fix(service): stop billing for a balancer that forwards nothing

A service without a single exposable port still got a Hetzner load
balancer created for it, charged by the hour, with no service behind it
and nothing but a log line to say so. Skip the balancer entirely in that
case, and drop the external IP the service advertises, so that clients
and ExternalDNS stop pointing at a balancer that no longer forwards.

Node selection moves behind a NodeSource decision that can be tested on
its own, not-ready nodes no longer take up target slots, and the
exclusion label now holds under every traffic policy, the way upstream
cloud providers treat it.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

* docs(readme): describe target trimming and services left without a balancer

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

---------

Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
Bumps [openssl](https://github.com/sfackler/rust-openssl) from 0.10.70 to 0.10.72.
- [Release notes](https://github.com/sfackler/rust-openssl/releases)
- [Commits](rust-openssl/rust-openssl@openssl-v0.10.70...openssl-v0.10.72)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.72
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.41.1 to 1.43.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](tokio-rs/tokio@tokio-1.41.1...tokio-1.43.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.43.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [ring](https://github.com/briansmith/ring) from 0.17.8 to 0.17.14.
- [Changelog](https://github.com/briansmith/ring/blob/main/RELEASES.md)
- [Commits](https://github.com/briansmith/ring/commits)

---
updated-dependencies:
- dependency-name: ring
  dependency-version: 0.17.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [serde_with](https://github.com/jonasbb/serde_with) from 3.11.0 to 3.24.0.
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](jonasbb/serde_with@v3.11.0...v3.24.0)

---
updated-dependencies:
- dependency-name: serde_with
  dependency-version: 3.24.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.72 to 0.10.81.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](rust-openssl/rust-openssl@openssl-v0.10.72...openssl-v0.10.81)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.81
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [rand](https://github.com/rust-random/rand) from 0.8.5 to 0.8.8.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/0.8.8/CHANGELOG.md)
- [Commits](rust-random/rand@0.8.5...0.8.8)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.8.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Adding the robotlb finalizer used a JSON merge patch that set
metadata.finalizers to a one-element list. A merge patch replaces a
list as a whole, so every finalizer another controller had put on the
Service was dropped. Removal wrote back the list read from the cache,
so a finalizer added or removed in between was lost or came back.

metadata.finalizers is merged by value in a strategic merge patch, so
adding now appends only robotlb's entry and removal deletes only that
entry with a deleteFromPrimitiveList directive.

Assisted-by: LLM

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.18 to 0.3.20.
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](tokio-rs/tracing@tracing-subscriber-0.3.18...tracing-subscriber-0.3.20)

---
updated-dependencies:
- dependency-name: tracing-subscriber
  dependency-version: 0.3.20
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.8.0 to 1.12.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](tokio-rs/bytes@v1.8.0...v1.12.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.12.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Preserve upstream master as a merge parent without replacing Forkline's
refactored controller, Helm deployment or observability additions.

Adapt upstream finalizer and exact-name lookup fixes to Forkline's
code layout. Never issue an unversioned last-chance finalizer merge.

Upstream: Treetscom/robotlb@7268ba2c
Ported-from: Treetscom#53, Treetscom#76
Use ready eligible cluster nodes for Cluster traffic policy and
endpoint-local nodes for Local policy, falling back to EndpointSlice
when a Service has no selector. Exclude control-plane-designated nodes.

Avoid creating a billed load balancer without an exposable NodePort,
and cap target planning to the Hetzner LB target limit.

Ported-from: Treetscom#31
Ported-from: Treetscom#25
Associate newly provisioned Hetzner balancers with the immutable
Service UID. Resolve or delete resources by ownership instead of name.
Conservatively adopt recognizable pre-upgrade balancers in place,
keeping public IPs and existing labels. Ignore foreign balancers.

Release on deletion or type/class change without parsing annotations;
reject unrecognized name collisions rather than taking over LBs.

Ported-from: Treetscom#62
Ported-from: Treetscom#47
Ported-from: Treetscom#64
Apply rustfmt changes reported by GitHub Actions, remove obsolete
unreferenced API helpers, and satisfy newer Clippy diagnostics.
Guard Hetzner API calls across all managed Services after a 429,
using a 60-second to 16-minute exponential pause. Avoid extending
the cooldown for already-in-flight requests. Preserve typed errors
from balancer creation so 429s are distinguishable.

Ported-from: Treetscom#37
…ions

Cover namespaced resource names, invalid annotations and conservative
adoption rules. Document upgrade/cleanup risks and how to audit
existing unlabelled Hetzner balancers.

Ported-from: Treetscom#62
Prevent repeated Service watch events caused by writing the same ingress
status after every reconcile. Adapt the API-call suppression principle
from upstream while retaining Forkline's controller structure.

Related-to: Treetscom#72
@forkline

forkline Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

✅ Fix pushed:

Fixed the CI failure by applying rustfmt formatting to the max_targets conditional in src/lb/mod.rs:370. The change splits a single-line if-else expression across multiple lines to satisfy rustfmt requirements. Pushed to branch and commented on PR #62.

Retry 409/423/5xx and robot_unavailable responses on target updates,
balancer type and algorithm changes with 1s/2s delay. Treat a target
already added by a previous attempt as success. 429 stays governed
by the shared rate-limit gate.

Ported-from: Treetscom#59
Related-to: Treetscom#66
@forkline

forkline Bot commented Oct 13, 2026

Copy link
Copy Markdown

⏸️ CI Auto-Fix paused

Maximum consecutive fix attempts (3/3) reached for this PR without success.
CI auto-fix is now paused to prevent infinite retry loops.

You can:

  • Review the CI logs and fix the issue manually
  • Use /reset-ci-auto-fix to give CI auto-fix another chance after making changes

To configure the maximum attempts, go to Repository Settings > Automation > CI Auto-Fix.

pando85 and others added 4 commits October 13, 2026 09:27
Extend transient retry handling to service add/update/delete and
network attach/detach, as in upstream busy-balancer handling.

Ported-from: Treetscom#66
Treat max_targets=0 as unlimited to prevent truncating all targets
when LoadBalancerType uses default values in tests.
@forkline

forkline Bot commented Oct 13, 2026

Copy link
Copy Markdown

Applied rustfmt formatting to max_targets conditional in src/lb/mod.rs.

@pando85
pando85 marked this pull request as ready for review October 13, 2026 08:45
@pando85
pando85 merged commit a1bc404 into master Oct 13, 2026
2 checks passed
@pando85
pando85 deleted the upstream/safety-and-reconciliation-2026-10 branch October 13, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants