Skip to content

feat(hetzner): ksail-owned floating IP primitives for a stable control-plane endpoint - #5699

Merged
devantler merged 2 commits into
mainfrom
claude/hetzner-floating-ip
Jul 2, 2026
Merged

devantler merged 2 commits into
mainfrom
claude/hetzner-floating-ip

Conversation

@devantler

Copy link
Copy Markdown
Contributor

🤖 Generated by the Daily AI Assistant

Fixes #5698. Foundation for devantler-tech/platform#2120 (Option A — Talos-managed hcloud floating-IP VIP, per the maintainer's on-issue choice).

What

Slice 1 of the stable control-plane endpoint work — the floating-IP primitives, following the provider's existing idempotent ensure pattern (EnsureNetwork/EnsureFirewall):

  • EnsureFloatingIP — get-by-name (<cluster>-floating-ip), create-if-absent (IPv4, homed in the cluster's location, ksail.owned labels).
  • AttachFloatingIPToServer / DetachFloatingIP — idempotent assign/unassign (no-op when already in the desired state), so create-retries re-assert instead of erroring.
  • deleteInfrastructure now releases the cluster's floating IP — but only when it carries the ksail.owned label: a user-managed reserved address that merely shares the name is never released (a released floating IP is unrecoverable). Server deletion only unassigns, so without this cluster delete would leak a billed address once slice 2 starts creating them.

No create-path behavior change yet: nothing calls EnsureFloatingIP in this slice. Slice 2 (follow-up child of #5698's plan) adds the spec.provider.hetzner config surface, endpoint computation from the floating IP, and the Talos VIP machine-config/cert-SAN rendering.

Design decision expressed here (platform#2120)

platform#2120's open question — where is the floating IP reserved declaratively? — is decided as (A) the KSail provisioner: KSail already owns the Hetzner servers/network/firewall via native hcloud-go (the standing native-Go-SDK provisioning direction), so the floating IP is a sibling resource; a whole Crossplane hcloud provider for one resource would be disproportionate. If you'd rather have (B), redirect here before promotion.

Validation

  • go test ./pkg/svc/provider/hetzner/ — 11 new tests (httptest-backed hcloud API fake, same pattern as the network-reconcile tests): ensure create/adopt/nil-client, attach no-op/assign/reassign, detach no-op/unassign, delete owned/unowned-skip/absent.
  • golangci-lint run ./pkg/svc/provider/hetzner/... — clean (after cache clean; the repo-wide local run only reports the known stale-worktree cache + local-version drift, CI is authoritative).
  • go build + full go test ./... — green except the known pkg/cli/cmd/open/chat parallel-run flake (127/127 pass alone).

…ntrol-plane endpoint

Adds EnsureFloatingIP (get-by-name, create-if-absent, ksail.owned labels,
<cluster>-floating-ip naming), idempotent AttachFloatingIPToServer /
DetachFloatingIP, and floating-IP cleanup in deleteInfrastructure so
cluster delete never leaks a billed reserved address. Nothing calls
EnsureFloatingIP yet — endpoint/config wiring is the next slice.

Foundation for devantler-tech/platform#2120 (Talos hcloud VIP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d1e9159a-6481-4161-9472-330ee0ba4e09

📥 Commits

Reviewing files that changed from the base of the PR and between df735aa and 3f3eb03.

📒 Files selected for processing (4)
  • pkg/svc/provider/hetzner/errors.go
  • pkg/svc/provider/hetzner/floating_ip.go
  • pkg/svc/provider/hetzner/floating_ip_test.go
  • pkg/svc/provider/hetzner/labels.go

📝 Walkthrough

Walkthrough

Adds Hetzner floating IP support: lookup-or-create, attach/detach, deletion, infrastructure cleanup wiring, ownership constants and errors, a test-only delete helper, and expanded tests for ownership and error handling.

Changes

Hetzner Floating IP Support

Layer / File(s) Summary
Floating IP suffix and ownership constants
pkg/svc/provider/hetzner/labels.go, pkg/svc/provider/hetzner/errors.go
Adds the floating IP suffix constant, a shared owned-label value constant, and the floating-IP ownership error.
Ensure, attach, detach, and delete floating IPs
pkg/svc/provider/hetzner/floating_ip.go
Implements floating IP lookup-or-create, idempotent attach and detach, and cluster floating IP deletion with ownership checks and lookup error propagation.
Infrastructure cleanup and test export
pkg/svc/provider/hetzner/infrastructure.go, pkg/svc/provider/hetzner/export_test.go
Deletes the cluster floating IP during infrastructure teardown and exposes the deletion helper through a test-only wrapper.
Floating IP provider tests
pkg/svc/provider/hetzner/floating_ip_test.go
Adds fixture data, fake Hetzner HTTP handling, call counters, and coverage for floating IP creation, ownership rejection, attach and detach behavior, delete scenarios, and lookup error propagation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Provider
  participant HetznerAPI

  Provider->>HetznerAPI: EnsureFloatingIP(clusterName, homeLocation)
  HetznerAPI-->>Provider: GetByName result
  alt not found
    Provider->>HetznerAPI: Create floating IP
    HetznerAPI-->>Provider: created floating IP
  else owned match
    Provider-->>Provider: return existing floating IP
  else unowned collision
    Provider-->>Provider: return ErrFloatingIPNotOwned
  end

  Provider->>HetznerAPI: AttachFloatingIPToServer or DetachFloatingIP
  alt already in desired state
    Provider-->>Provider: no-op
  else
    Provider->>HetznerAPI: Assign or Unassign action
    HetznerAPI-->>Provider: action result
  end

  Provider->>HetznerAPI: deleteFloatingIP(clusterName)
  alt ksail-owned and present
    Provider->>HetznerAPI: Delete floating IP
  else missing or unowned
    Provider-->>Provider: skip
  end
Loading

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the Hetzner floating IP primitives added for a stable control-plane endpoint.
Description check ✅ Passed The description matches the floating IP helpers, cleanup changes, tests, and design decision in this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/hetzner-floating-ip

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

✅MegaLinter analysis: Success

✅ Linters with no issues

actionlint, bash-exec, git_diff, hadolint, jscpd, jsonlint, lychee, markdown-table-formatter, markdownlint, prettier, prettier, shellcheck, shfmt, stylelint, syft, trivy-sbom, trufflehog, v8r, v8r, yamllint

Notices

📣 MegaLinter 9.5.0 is out! Discover the new features and security recommendations in the release announcement. (Skip this info by defining SECURITY_SUGGESTIONS: false)

See detailed reports in MegaLinter artifacts

MegaLinter is graciously provided by OX Security
Show us your support by starring ⭐ the repository

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
pkg/svc/provider/hetzner/floating_ip_test.go (2)

31-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a params struct for the fake server constructor.

newFloatingIPServer takes seven parameters, most of which are counter pointers; grouping them into a *floatingIPCounters (already used elsewhere as a return type) would reduce parameter-list noise.

♻️ Proposed refactor
-func newFloatingIPServer(
-	t *testing.T,
-	listJSON string,
-	createCalls, deleteCalls, assignCalls, unassignCalls *int32,
-	lastCreateBody *atomic.Value,
-) *httptest.Server {
+func newFloatingIPServer(
+	t *testing.T,
+	listJSON string,
+	counters *floatingIPCounters,
+) *httptest.Server {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/svc/provider/hetzner/floating_ip_test.go` around lines 31 - 78,
`newFloatingIPServer` has an overly long parameter list made up mostly of
counter pointers, which is noisy and hard to maintain. Refactor the test helper
to accept a single `*floatingIPCounters` (the same struct already used
elsewhere) plus the remaining inputs, and update the call sites accordingly.
Keep the helper behavior the same by reading the individual counters from that
struct inside `newFloatingIPServer`, `registerFloatingIPActionHandlers`, and the
`/floating_ips` handlers.

176-245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing nil-client coverage for Attach/Detach.

AttachFloatingIPToServer and DetachFloatingIP both return provider.ErrProviderUnavailable when p.client == nil (per the upstream implementation), mirroring EnsureFloatingIP. Only EnsureFloatingIP has a TestEnsureFloatingIP_NilClient counterpart; the attach/detach nil-client branches are untested.

✅ Suggested additional tests
func TestAttachFloatingIPToServer_NilClient(t *testing.T) {
	t.Parallel()

	prov := hetzner.NewProvider(nil)

	err := prov.AttachFloatingIPToServer(
		t.Context(),
		&hcloudtest.FloatingIP{ID: 7},
		&hcloudtest.Server{ID: 9},
	)

	require.ErrorIs(t, err, provider.ErrProviderUnavailable)
}

func TestDetachFloatingIP_NilClient(t *testing.T) {
	t.Parallel()

	prov := hetzner.NewProvider(nil)

	err := prov.DetachFloatingIP(t.Context(), &hcloudtest.FloatingIP{ID: 7})

	require.ErrorIs(t, err, provider.ErrProviderUnavailable)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/svc/provider/hetzner/floating_ip_test.go` around lines 176 - 245, Add
nil-client test coverage for the floating IP attach/detach paths:
`AttachFloatingIPToServer` and `DetachFloatingIP` should be verified to return
`provider.ErrProviderUnavailable` when the provider is created with a nil
client, matching the existing `EnsureFloatingIP_NilClient` behavior. Add focused
tests alongside the existing `TestAttachFloatingIPToServer_*` and
`TestDetachFloatingIP_*` cases, using `hetzner.NewProvider(nil)` and asserting
the error with `require.ErrorIs`.
pkg/svc/provider/hetzner/floating_ip.go (2)

130-133: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Delete call isn't retried like the preceding lookup.

GetByName is wrapped in retryTransientHetznerOperation, but the Delete call directly below is not, despite deleteInfrastructure aborting the whole cleanup chain on any error here. For consistency with the retry pattern already established in this function (and elsewhere in this file's cleanup path), consider wrapping Delete similarly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/svc/provider/hetzner/floating_ip.go` around lines 130 - 133, The delete
path in `deleteFloatingIP` skips the same transient retry handling used by
`GetByName`, so wrap `p.client.FloatingIP.Delete` with
`retryTransientHetznerOperation` just like the lookup above. Keep the existing
error wrapping with the `floatingIPName` context, and ensure the cleanup flow in
`deleteInfrastructure` benefits from the retry behavior when deleting the
floating IP.

40-45: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider hcloud.Ptr instead of new(...) for the pointer conversion.

Line 42's new(floatingIPName) relies on the Go 1.26 language change that lets new take an expression (not just a type) — valid given this repo's declared 1.26.1+ toolchain, but it's a very recent, less-familiar idiom that diverges from the hcloud-go library's own convention (hcloud.Ptr), which is used for this exact purpose elsewhere in the SDK.

♻️ Proposed alignment with library convention
 	result, _, err := p.client.FloatingIP.Create(ctx, hcloud.FloatingIPCreateOpts{
 		Type:         hcloud.FloatingIPTypeIPv4,
-		Name:         new(floatingIPName),
+		Name:         hcloud.Ptr(floatingIPName),
 		HomeLocation: &hcloud.Location{Name: homeLocation},
 		Labels:       ResourceLabels(clusterName),
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/svc/provider/hetzner/floating_ip.go` around lines 40 - 45, Replace the
pointer conversion in the FloatingIP creation path with the hcloud library’s
pointer helper to match its convention. In the floating IP creation call inside
the relevant provider function, swap out the current name pointer expression for
hcloud.Ptr so the code aligns with other hcloud-go usages and avoids the newer,
less familiar idiom.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/svc/provider/hetzner/floating_ip.go`:
- Around line 30-38: Ensure EnsureFloatingIP does not adopt a floating IP solely
by name; after p.client.FloatingIP.GetByName returns a match, verify the
ksail.owned label before returning it. If the label is absent or false, treat it
as not owned and continue creating/handling a new floating IP instead of reusing
the user-managed one. Use the existing ownership pattern from deleteFloatingIP
as the reference for the label check.
- Around line 108-124: The lookup in floatingIPByName is swallowing every
GetByName failure after retries, which can hide real API problems and leave a
billed address behind. Update the logic around retryTransientHetznerOperation in
floating_ip.go to distinguish a true “not found” case from other errors: only
return nil for the missing-resource case, and propagate or surface any other
error instead of treating it as absent. Also add an actual log call in the
ignored-path so the skipped floating IP can be observed.

---

Nitpick comments:
In `@pkg/svc/provider/hetzner/floating_ip_test.go`:
- Around line 31-78: `newFloatingIPServer` has an overly long parameter list
made up mostly of counter pointers, which is noisy and hard to maintain.
Refactor the test helper to accept a single `*floatingIPCounters` (the same
struct already used elsewhere) plus the remaining inputs, and update the call
sites accordingly. Keep the helper behavior the same by reading the individual
counters from that struct inside `newFloatingIPServer`,
`registerFloatingIPActionHandlers`, and the `/floating_ips` handlers.
- Around line 176-245: Add nil-client test coverage for the floating IP
attach/detach paths: `AttachFloatingIPToServer` and `DetachFloatingIP` should be
verified to return `provider.ErrProviderUnavailable` when the provider is
created with a nil client, matching the existing `EnsureFloatingIP_NilClient`
behavior. Add focused tests alongside the existing
`TestAttachFloatingIPToServer_*` and `TestDetachFloatingIP_*` cases, using
`hetzner.NewProvider(nil)` and asserting the error with `require.ErrorIs`.

In `@pkg/svc/provider/hetzner/floating_ip.go`:
- Around line 130-133: The delete path in `deleteFloatingIP` skips the same
transient retry handling used by `GetByName`, so wrap
`p.client.FloatingIP.Delete` with `retryTransientHetznerOperation` just like the
lookup above. Keep the existing error wrapping with the `floatingIPName`
context, and ensure the cleanup flow in `deleteInfrastructure` benefits from the
retry behavior when deleting the floating IP.
- Around line 40-45: Replace the pointer conversion in the FloatingIP creation
path with the hcloud library’s pointer helper to match its convention. In the
floating IP creation call inside the relevant provider function, swap out the
current name pointer expression for hcloud.Ptr so the code aligns with other
hcloud-go usages and avoids the newer, less familiar idiom.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 81881cb7-942a-4963-b39f-336c0aa058a7

📥 Commits

Reviewing files that changed from the base of the PR and between 8c4562c and df735aa.

📒 Files selected for processing (5)
  • pkg/svc/provider/hetzner/export_test.go
  • pkg/svc/provider/hetzner/floating_ip.go
  • pkg/svc/provider/hetzner/floating_ip_test.go
  • pkg/svc/provider/hetzner/infrastructure.go
  • pkg/svc/provider/hetzner/labels.go

Comment thread pkg/svc/provider/hetzner/floating_ip.go Resolved
Comment thread pkg/svc/provider/hetzner/floating_ip.go Resolved
@github-code-quality

github-code-quality Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go / code-coverage/go

The overall coverage in the claude/hetzner-float... branch remains at 64%, unchanged from the main branch.

Show a code coverage summary of the most impacted files.
File main d5bc194 claude/hetzner-float... 3f3eb03 +/-
pkg/client/reconciler/poll.go 84% 80% -4%
pkg/svc/provisi...etzner/token.go 77% 73% -4%
pkg/svc/provide...frastructure.go 40% 40% 0%
pkg/svc/provisi...storage_gate.go 83% 86% +3%
pkg/svc/provide.../floating_ip.go 0% 80% +80%
pkg/svc/mirror/inject.go 0% 90% +90%

Updated July 02, 2026 14:33 UTC
Code Coverage is in Public Preview. Learn more and provide us with your feedback.

…ookup errors on delete

EnsureFloatingIP no longer silently adopts a user-managed reserved address
that merely shares the conventional name — it now requires the ksail.owned
label (same guard deleteFloatingIP applies) and surfaces the name collision
as ErrFloatingIPNotOwned. deleteFloatingIP now propagates lookup failures
after transient retries instead of treating every error as "not found",
so a persistent API failure can no longer silently leak a billed reserved
address (GetByName reports absence as nil/nil, never an error).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@devantler
devantler marked this pull request as ready for review July 2, 2026 15:50
@devantler
devantler merged commit ff68abd into main Jul 2, 2026
3 checks passed
@github-project-automation github-project-automation Bot moved this from 🫴 Ready to ✅ Done in 🌊 Project Board Jul 2, 2026
@devantler
devantler deleted the claude/hetzner-floating-ip branch July 2, 2026 16:13
devantler added a commit that referenced this pull request Jul 2, 2026
…g for Talos × Hetzner (#5719)

* feat(hetzner): floating-IP config surface + stable API endpoint wiring for Talos × Hetzner

Adds the opt-in config surface for the stable-API-endpoint lane
(platform#2120 option A): floatingIPEnabled + floatingIPLocation on
OptionsHetzner. When enabled, cluster create ensures the cluster-owned
floating IP (EnsureFloatingIP, #5699), attaches it to the first
control-plane server, and renders it as the cluster endpoint with the
floating IP plus every control-plane node IP in the certificate SANs
(node IPs must stay verifiable — readiness checks and break-glass access
dial nodes directly). Disabled (default) renders byte-identical configs.

Node-side ownership handover (Talos VIP block) ships separately (#5718);
until then the address must be claimed via a user Talos patch.

Fixes #5714

* chore: sync modules and update generated files

---------

Co-authored-by: devantler <26203420+devantler@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

Hetzner: ksail-owned floating IP for a stable control-plane endpoint (Talos VIP foundation)

1 participant