Skip to content

Commit d194bef

Browse files
committed
feat: add observe mode and a startup grace to the orphan sweep
Three changes aimed at making this safe to adopt on a fleet nobody has audited. OBSERVE MODE. This controller deletes machines, and an operator adopting it could otherwise only learn what it would do to their fleet by letting it do it. A kill switch is reactive; observe mode is not. It runs every check, emits every signal, and changes nothing -- not the fleet, not the servers' labels, nothing outside the cluster. INSTANCE_GARBAGE_COLLECTION_MODE replaces the boolean, since three states do not fit in one, and an unrecognised value refuses to start rather than falling back to the mode that deletes. A STARTUP GRACE. Grace is counted in consecutive sweeps, which a restart or a leader handover resets -- and the instability that strands servers is exactly what causes those. A fresh process could otherwise reach the threshold having watched the cluster for only a few minutes. It must now also have been sweeping for a full window before it may reclaim anything, so operator instability delays reclamation rather than either skipping it forever or authorising it on a short history. This is what makes in-process counting safe rather than merely simple, and it follows core: nodeclaim/consistency keeps first-seen times in an in-process cache and writes nothing durable. Core's durable timeouts hang off status conditions on objects it owns, never off the cloud resource. An earlier revision of this branch stored the clock on the Hetzner server itself; it was withdrawn because it made a safety-critical invariant depend on a remote write that can fail silently, on a clock written by a process that may be gone, and on state an operator can edit with one CLI command. NODE EVENTS. Reclamations are recorded as GarbageCollected, and observe-mode candidates as WouldGarbageCollect, typed Normal -- reclaiming an orphan is this controller working, not a fault, and a cluster alerting on Warning events against Nodes should not page every time the sweep does its job. Most orphans never registered a Node, so this supplements the log and the metric rather than replacing them, and the doc comment says so. Three chart-level holes closed alongside them. The ClusterRole now grants events.k8s.io, without which the manager's recorder has every event rejected 403 while the README tells operators to validate observe mode by reading them. The mode is emitted unconditionally rather than through a "with" block that skips falsy values, so "mode: false" -- the natural typo when migrating from the boolean this replaces -- reaches the parser and stops the operator instead of silently defaulting to enabled. And a "fail" guard rejects an upgrade still carrying the removed "disabled" key, which Helm would otherwise merge while the sweep ran on a fleet the operator believed was paused. The clock is injected, matching core, so the startup grace is testable without reaching into a fake provider.
1 parent 6cb13e7 commit d194bef

16 files changed

Lines changed: 561 additions & 154 deletions

File tree

README.md

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ version tag in production.
2525
- **Talos Linux and Ubuntu images**, resolved per architecture.
2626
- **Placement groups** for spreading nodes across physical hosts.
2727
- **Cost controls** — opt out of the billed public IPv4 (and/or IPv6) per node class for private-network clusters.
28-
- **Multi-cluster safe** — every managed server is tagged with the cluster name, so several clusters can share one Hetzner project without touching each other's nodes.
28+
- **Multi-cluster safe** — every managed server is tagged with the cluster name and the cluster's `kube-system` UID, so several clusters can share one Hetzner project without touching each other's nodes. Servers created before the UID label existed are matched on name alone, so give each cluster a distinct `clusterName` until the fleet has rolled.
2929

3030
## How it works
3131

@@ -66,22 +66,53 @@ Two mechanisms cover this:
6666
cluster and this NodeClaim and matches the requested type, location and image.
6767
- **Garbage collection.** A sweep every two minutes reclaims servers Karpenter
6868
has no NodeClaim for, along with the Node objects they left behind. A server
69-
must be seen reapable on three consecutive sweeps, and one whose node is
69+
must be seen unowned on several consecutive sweeps, and one whose node is
7070
registered and still `Ready` is never touched — a machine carrying workloads is
71-
core's to drain, not this sweep's to destroy. Sparing a server restarts its
72-
count, so a machine the `Ready` guard protected does not sit on a spent grace
73-
window waiting for its first NotReady blip.
71+
core's to drain, not this sweep's to destroy.
72+
73+
Every path that declines to act resets the count, so the window always measures
74+
an uninterrupted run of sweeps that found nothing in the way; a machine the
75+
`Ready` guard protected never sits on a spent window waiting for its first
76+
NotReady blip. The count is per-process, so a restart or leader handover starts
77+
it again — and the operator must additionally have been sweeping for a full
78+
window before it may reclaim anything, so instability delays reclamation rather
79+
than authorising it on a short history.
7480

7581
**`clusterName` must be unique per cluster within a Hetzner project.** Servers
7682
are labelled with it, and the sweep uses that label to decide what it owns. Two
7783
clusters sharing a name in one project would each see the other's servers as
7884
unclaimed. The operator therefore also stamps the UID of the cluster's
7985
`kube-system` namespace on every server it creates and refuses to touch a server
80-
carrying a different one, logging the collision once. That protects servers
81-
created from this version onward; servers predating it carry no UID and are
82-
still matched on name alone, so distinct names remain the thing to get right.
83-
84-
Set `instanceGarbageCollection.disabled: true` to pause the sweep during
86+
carrying a different one, logging the collision once and counting it as
87+
`karpenter_hetzner_orphaned_server_gc_total{result="skipped_foreign_cluster"}` on
88+
every sweep.
89+
90+
Two things this does not cover. It protects servers created from this version
91+
onward; servers predating it carry no UID and are still matched on name alone, so
92+
until a fleet has fully rolled, distinct names remain the thing to get right.
93+
And the UID identifies the *control plane*, not the servers: rebuilding a cluster
94+
from scratch mints a new `kube-system` UID, after which the previous
95+
incarnation's servers are refused forever — never reclaimed, still billing. The
96+
`skipped_foreign_cluster` counter is the signal for both. Recovering from a
97+
rebuild means relabelling those servers with the new UID
98+
(`hcloud server add-label <server> karpenter.sh/cluster-uid=<uid>`, where `<uid>`
99+
is `kubectl get ns kube-system -o jsonpath='{.metadata.uid}'`) or deleting them
100+
by hand.
101+
102+
> **Upgrading an existing cluster.** This version adds a controller that
103+
> **deletes Hetzner servers**. On first start it reclaims every server in the
104+
> project that carries this cluster's labels and has no NodeClaim — which is the
105+
> point, but on a fleet nobody has audited it is worth seeing first.
106+
>
107+
> Set `instanceGarbageCollection.mode: observe` to run every check and report
108+
> what *would* be reclaimed without deleting anything. Watch
109+
> `karpenter_hetzner_orphaned_server_gc_total{result="would_reap"}` and the
110+
> `WouldGarbageCollect` events on the affected Nodes, satisfy yourself the list
111+
> is right, then switch to `enabled`. Reclamations are recorded as
112+
> `GarbageCollected` events on the Node, so `kubectl describe node` explains a
113+
> server that disappeared.
114+
115+
Set `instanceGarbageCollection.mode: disabled` to pause the sweep during
85116
maintenance that removes NodeClaims wholesale (reinstalling the CRDs, restoring
86117
etcd, clearing finalizers by hand), so it does not act on a cluster that only
87118
looks empty. Provisioning and disruption keep working while it is off.
@@ -224,7 +255,7 @@ comments explaining every field.
224255
|---------|----------|-------------|
225256
| `HCLOUD_TOKEN` | yes | Hetzner Cloud API token |
226257
| `CLUSTER_NAME` | yes | Cluster identifier; scopes managed servers. Must be unique per Hetzner project — two clusters sharing a value will reclaim each other's servers |
227-
| `DISABLE_INSTANCE_GARBAGE_COLLECTION` | no (unset) | Set to `true` to pause the orphaned-server sweep (chart value: `instanceGarbageCollection.disabled`) |
258+
| `INSTANCE_GARBAGE_COLLECTION_MODE` | no (`enabled`) | `enabled`, `observe` or `disabled`; an unrecognised value stops the operator starting (chart value: `instanceGarbageCollection.mode`) |
228259
| `METRICS_PORT` | no (8080) | Prometheus metrics port |
229260
| `HEALTH_PROBE_PORT` | no (8081) | Health/readiness probe port |
230261

charts/karpenter-provider-hetzner/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ annotations:
3232
artifacthub.io/containsSecurityUpdates: "false"
3333
artifacthub.io/changes: |
3434
- kind: added
35-
description: "Reclaim Hetzner servers Karpenter no longer has a NodeClaim for, which would otherwise run and bill unowned; pause the sweep with instanceGarbageCollection.disabled during maintenance that removes NodeClaims wholesale"
35+
description: "Reclaim Hetzner servers Karpenter no longer has a NodeClaim for, which would otherwise run and bill unowned; set instanceGarbageCollection.mode to observe to preview it, or disabled to pause it during maintenance that removes NodeClaims wholesale"

charts/karpenter-provider-hetzner/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Existing `v1alpha1` objects are not migrated automatically; recreate them under
3737
| Key | Default | Description |
3838
|-----|---------|-------------|
3939
| `clusterName` | `""` (required) | Scopes which servers the controller manages; must be unique per Hetzner project |
40-
| `instanceGarbageCollection.disabled` | `false` | Pause the sweep that reclaims servers with no NodeClaim (see values.yaml) |
40+
| `instanceGarbageCollection.mode` | `enabled` | `enabled`, `observe` (report what would be reclaimed, delete nothing) or `disabled` (see values.yaml) |
4141
| `replicas` | `1` | Controller replicas |
4242
| `image.repository` | `ghcr.io/paperclipinc/karpenter-provider-hetzner` | Image |
4343
| `image.tag` | `""` | Empty tracks the chart appVersion; pin a tag in production |
@@ -87,6 +87,12 @@ When `serviceMonitor.enabled=true` the chart creates:
8787
- a `Service` named `karpenter-provider-hetzner-metrics` exposing port `http-metrics`
8888
- a `ServiceMonitor` that selects that Service and scrapes `/metrics` at the configured interval
8989

90-
Requires the [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator) CRDs to be present. The controller exposes provider metrics under the `karpenter_hetzner_` prefix (server creates/deletes, durations, drift reasons, instance-type cache hits/misses, orphaned-server garbage-collection outcomes, adopted servers, and raw hcloud API call counts).
90+
Requires the [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator) CRDs to be present. The controller exposes provider metrics under the `karpenter_hetzner_` prefix (server creates/deletes, durations, drift reasons, instance-type cache hits/misses, orphaned-server garbage-collection outcomes, adopted servers, unpriceable nodes, and raw hcloud API call counts).
9191

92-
Worth an alert: `karpenter_hetzner_orphaned_server_gc_total{result="error"}` means a server cannot be reclaimed and is still billing, and `karpenter_hetzner_server_adopt_total{result="declined"}` means a NodeClaim keeps colliding with a server adoption refuses to take.
92+
Worth an alert:
93+
94+
- `karpenter_hetzner_orphaned_server_gc_total{result="error"}` — a server cannot be reclaimed and is still billing.
95+
- `karpenter_hetzner_server_adopt_total{result="declined"}` — a NodeClaim keeps colliding with a server adoption refuses to take.
96+
- `karpenter_hetzner_orphaned_server_gc_total{result="skipped_foreign_cluster"}` — servers carry this cluster's `clusterName` but another cluster's UID. Either two clusters share a name in one Hetzner project, or this cluster's control plane was rebuilt and these servers predate it. Either way they will never be reclaimed.
97+
98+
Note that the metrics endpoint is scraped on every replica, but the sweeps behind these metrics run only on the leader. Aggregate with `max()` rather than `avg()`/`min()`, or a standby's zero will read as a healthy cluster.

charts/karpenter-provider-hetzner/templates/deployment.yaml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,17 @@ spec:
6868
key: {{ .Values.auth.secretRef.key }}
6969
- name: CLUSTER_NAME
7070
value: {{ .Values.clusterName | quote }}
71-
{{- if .Values.instanceGarbageCollection.disabled }}
72-
- name: DISABLE_INSTANCE_GARBAGE_COLLECTION
73-
value: "true"
71+
{{- if hasKey .Values.instanceGarbageCollection "disabled" }}
72+
{{- fail "instanceGarbageCollection.disabled has been replaced by instanceGarbageCollection.mode (enabled|observe|disabled). Your existing value is being ignored, which would leave the orphaned-server sweep RUNNING. Set mode explicitly and remove the disabled key." }}
7473
{{- end }}
74+
# Emitted unconditionally rather than through `with`, which skips
75+
# falsy values: `mode: false` -- the natural typo when migrating from
76+
# the boolean this replaced -- would omit the variable, the operator
77+
# would default to enabled, and a value meant to stop the sweep would
78+
# start it. Passed through so an invalid value reaches parseGCMode,
79+
# which refuses to start.
80+
- name: INSTANCE_GARBAGE_COLLECTION_MODE
81+
value: {{ .Values.instanceGarbageCollection.mode | quote }}
7582
- name: METRICS_PORT
7683
value: {{ .Values.metrics.port | quote }}
7784
- name: HEALTH_PROBE_PORT

charts/karpenter-provider-hetzner/templates/rbac.yaml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,23 @@ rules:
1515
- apiGroups: [""]
1616
resources: ["nodes", "nodes/status"]
1717
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
18-
# Core resources Karpenter's scheduling simulation observes.
18+
# Core resources Karpenter's scheduling simulation observes. "namespaces" is
19+
# also load-bearing for startup: the operator reads the kube-system namespace's
20+
# UID to identify this cluster and refuses to start without it, so narrowing
21+
# this rule takes the whole controller down, not just scheduling.
1922
- apiGroups: [""]
2023
resources: ["pods", "persistentvolumes", "persistentvolumeclaims", "replicationcontrollers", "namespaces"]
2124
verbs: ["get", "list", "watch"]
2225
- apiGroups: [""]
2326
resources: ["pods/eviction"]
2427
verbs: ["create"]
25-
- apiGroups: [""]
28+
# Karpenter core and leader election use the deprecated core-group recorder;
29+
# this provider's garbage collector uses the manager's recorder, which writes
30+
# to events.k8s.io/v1. RBAC authorizes on (apiGroup, resource), so the core
31+
# group alone would 403 every GarbageCollected event -- silently, since only a
32+
# klog line reports it, while the README tells operators to validate observe
33+
# mode by reading those very events.
34+
- apiGroups: ["", "events.k8s.io"]
2635
resources: ["events"]
2736
verbs: ["create", "patch"]
2837
- apiGroups: [""]

charts/karpenter-provider-hetzner/values.yaml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,22 @@ args: []
5353
clusterName: ""
5454

5555
# The operator reclaims servers Karpenter no longer has a NodeClaim for, which
56-
# would otherwise run and bill with nothing pointing at them. Set disabled: true
57-
# to pause that sweep during maintenance that removes NodeClaims wholesale —
58-
# reinstalling the CRDs, restoring etcd, clearing finalizers by hand — so it does
59-
# not act on a cluster that only looks empty. Provisioning and disruption keep
60-
# working while it is off. Remember to turn it back on.
56+
# would otherwise run and bill with nothing pointing at them.
6157
instanceGarbageCollection:
62-
disabled: false
58+
# enabled - reclaim orphaned servers.
59+
# observe - run every check and report what WOULD be reclaimed, deleting
60+
# nothing. Start here on an existing fleet: watch
61+
# karpenter_hetzner_orphaned_server_gc_total{result="would_reap"},
62+
# satisfy yourself it names the right machines, then switch to
63+
# enabled. A kill switch only helps after something has gone wrong.
64+
# disabled - do not sweep at all. For maintenance that removes NodeClaims
65+
# wholesale (reinstalling the CRDs, restoring etcd, clearing
66+
# finalizers by hand), so the sweep does not act on a cluster that
67+
# only looks empty. Provisioning and disruption keep working.
68+
#
69+
# An unrecognised value stops the operator starting rather than falling back
70+
# to enabled: a typo here would reap the fleet it was meant to protect.
71+
mode: enabled
6372

6473
metrics:
6574
port: 8080

cmd/controller/main.go

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package main
22

33
import (
4+
"context"
5+
"time"
6+
47
"sigs.k8s.io/controller-runtime/pkg/log"
58

69
// Register karpenter core types into the default k8s scheme.
@@ -25,6 +28,10 @@ import (
2528
"github.com/paperclipinc/karpenter-provider-hetzner/pkg/providers/instancetype"
2629
)
2730

31+
// clusterUIDTimeout bounds the one API-server read the operator makes before the
32+
// manager -- and therefore the health probes -- are running.
33+
const clusterUIDTimeout = 30 * time.Second
34+
2835
func main() {
2936
ctx, op := operator.NewOperator()
3037

@@ -41,19 +48,26 @@ func main() {
4148
return
4249
}
4350

44-
// Create the three providers.
4551
// Identify this cluster independently of its operator-chosen name. CLUSTER_NAME
4652
// is not guaranteed unique, and two clusters sharing one in a single Hetzner
4753
// project would otherwise each treat the other's servers as its own -- which
4854
// now means deleting them. The kube-system UID is unique per cluster and
4955
// stable for its lifetime. Read through the API reader because the manager's
5056
// cache is not running yet.
51-
clusterUID, err := hetznerop.ClusterUID(ctx, op.GetAPIReader())
57+
//
58+
// Bound it: this runs before the manager starts, so the health probes are not
59+
// listening yet and an apiserver that accepts the connection but never answers
60+
// would hang the process where nothing can observe it. The rest config sets no
61+
// per-request timeout of its own.
62+
uidCtx, cancelUID := context.WithTimeout(ctx, clusterUIDTimeout)
63+
clusterUID, err := hetznerop.ClusterUID(uidCtx, op.GetAPIReader())
64+
cancelUID()
5265
if err != nil {
5366
log.FromContext(ctx).Error(err, "failed to read the cluster UID")
5467
return
5568
}
5669

70+
// Create the three providers.
5771
instanceProvider := instance.NewProviderWithPlacementGroups(&hcloudClient.Server, &hcloudClient.PlacementGroup, cfg.ClusterName, clusterUID, &hcloudClient.Action)
5872
typeProvider := instancetype.NewProvider(&hcloudClient.ServerType)
5973
imageProvider := imagefamily.NewProvider(&hcloudClient.Image)
@@ -75,23 +89,36 @@ func main() {
7589
// Our NodeClass status controller (network + image validation, Ready).
7690
nodeClassController := nodeclass.NewController(op.GetClient(), &hcloudClient.Network, &hcloudClient.Firewall, &hcloudClient.SSHKey, imageProvider)
7791

92+
providerControllers := []controller.Controller{nodeClassController}
7893
// Reap servers whose NodeClaim is gone. Karpenter core only garbage collects
7994
// the opposite direction (NodeClaims with no instance), so without this an
8095
// orphaned server runs and bills indefinitely.
81-
providerControllers := []controller.Controller{nodeClassController}
82-
if cfg.DisableInstanceGarbageCollection {
96+
//
97+
// Every mode is logged, not just the unusual ones: this controller deletes
98+
// machines, so which mode took effect must be answerable from the operator's
99+
// own startup logs rather than inferred from a values file.
100+
//
101+
// Every mode is named explicitly and `default` refuses to start. Routing the
102+
// unknown case to the deleting branch would re-open, one layer down, exactly
103+
// the hole parseGCMode exists to close: GCMode's zero value is "", not
104+
// "enabled", so any Config built without LoadConfig -- or any mode added to
105+
// the parser and forgotten here -- would silently select "delete servers".
106+
switch cfg.InstanceGarbageCollectionMode {
107+
case hetznerop.GCDisabled:
83108
log.FromContext(ctx).Info("instance garbage collection is disabled; " +
84109
"servers whose NodeClaim is gone will not be reclaimed")
85-
} else {
86-
// Log the enabled case too. DISABLE_INSTANCE_GARBAGE_COLLECTION leaves the
87-
// sweep running on any value it does not recognise, so a line for one state
88-
// only would let a typo'd pause ("disabled", "True!") look identical to a
89-
// pause that took effect -- on the one flag whose job is protecting a fleet
90-
// during maintenance that removes NodeClaims wholesale.
91-
log.FromContext(ctx).Info("instance garbage collection is enabled; " +
92-
"servers whose NodeClaim is gone will be reclaimed")
110+
case hetznerop.GCObserve, hetznerop.GCEnabled:
111+
mode := instancegc.Mode(cfg.InstanceGarbageCollectionMode)
112+
log.FromContext(ctx).Info("instance garbage collection is active",
113+
"mode", string(mode),
114+
"reclaims", mode == instancegc.ModeEnabled)
93115
providerControllers = append(providerControllers,
94-
instancegc.NewController(op.GetClient(), instanceProvider, cfg.ClusterName, clusterUID))
116+
instancegc.NewController(op.GetClient(), instanceProvider,
117+
cfg.ClusterName, clusterUID, mode, op.Clock))
118+
default:
119+
log.FromContext(ctx).Error(nil, "unhandled instance garbage collection mode; refusing to start",
120+
"mode", string(cfg.InstanceGarbageCollectionMode))
121+
return
95122
}
96123

97124
// Wire and start all controllers.

pkg/apis/v1/labels.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,28 @@ const (
3434
// label, and is treated as ours so existing fleets stay managed.
3535
ServerLabelClusterUID = "karpenter.sh/cluster-uid"
3636
)
37+
38+
// OwnedByCluster reports whether a server's labels mark it as belonging to the
39+
// installation identified by clusterName and clusterUID.
40+
//
41+
// This is the single definition of ownership. It is consulted from two places
42+
// that both act destructively on the answer -- the orphan sweep deletes, and
43+
// adoption hands a live machine to Karpenter, which eventually terminates it --
44+
// so the rule they apply has to be one rule. The legacy exemption below is the
45+
// part that must not drift: it is a migration affordance that will be tightened
46+
// once fleets have rolled, and tightening it in one caller but not the other
47+
// would either strand every pre-UID orphan or resume cross-cluster deletion.
48+
//
49+
// A UID that is present and different belongs to another cluster. A missing UID
50+
// predates the label and is treated as ours, because refusing those would strand
51+
// every server created before it existed.
52+
func OwnedByCluster(labels map[string]string, clusterName, clusterUID string) bool {
53+
if labels[ServerLabelManagedBy] != ServerValueManagedBy {
54+
return false
55+
}
56+
if labels[ServerLabelCluster] != clusterName {
57+
return false
58+
}
59+
uid := labels[ServerLabelClusterUID]
60+
return uid == "" || uid == clusterUID
61+
}

pkg/cloudprovider/cloudprovider_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ func (f *fakeServerClient) AllWithOpts(_ context.Context, _ hcloud.ServerListOpt
101101
return out, nil
102102
}
103103

104+
func (f *fakeServerClient) Update(_ context.Context, server *hcloud.Server, opts hcloud.ServerUpdateOpts) (*hcloud.Server, *hcloud.Response, error) {
105+
s, ok := f.servers[server.ID]
106+
if !ok {
107+
return nil, nil, hcloud.Error{Code: hcloud.ErrorCodeNotFound, Message: "not found"}
108+
}
109+
if opts.Labels != nil {
110+
s.Labels = opts.Labels
111+
}
112+
return s, nil, nil
113+
}
114+
104115
type fakeServerTypeClient struct{ types []*hcloud.ServerType }
105116

106117
func (f *fakeServerTypeClient) All(_ context.Context) ([]*hcloud.ServerType, error) {

0 commit comments

Comments
 (0)