Skip to content

Commit 579fba4

Browse files
stubbiclaude
andauthored
Provider production-hardening: deployable, correct, tested (#1)
* docs: spec for provider production-hardening Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: implementation plan for provider production-hardening Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: align Go toolchain to 1.26 across build, CI, and release * feat: generate and ship HCloudNodeClass CRD with CI drift guard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add public-IP flags and named NodeClass status conditions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: add kubebuilder defaults for public-IP flags; test explicit-true Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: require CLUSTER_NAME and scope managed servers by cluster label Adds LoadConfig() reading CLUSTER_NAME from the environment (required), threads the cluster name into instance.Provider, tags every created server with karpenter.sh/cluster=<name>, and filters List() by both the managed-by and cluster labels so two clusters in one Hetzner project cannot see each other's servers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: validate CLUSTER_NAME charset; assert cluster scoping in List/Create tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: map Hetzner capacity errors to Karpenter InsufficientCapacityError Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: wait for create actions, set public-IP opts, map create errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: resolve public-IP defaults via spec helpers; test wait branches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: use net hourly pricing; document IPv4 cost lever Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: use Net prices in instancetype test fixture Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: cache unavailable offerings and skip them after capacity errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document availability-copy semantics, mark timing, and cache bound Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: complete drift detection (firewall, server-type); reusable test harness Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: make server-type drift test explicit; document firewall subset + label-skip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: NodeClass status controller (network + image validation, Ready) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: ResolvedImage keyed by architecture; periodic NodeClass resync; image-failure test * fix: register provider node labels as Karpenter well-known labels Without this, CloudProvider.Create rejected every instance type because the custom cpu-type/server-family requirement labels were undefined, breaking all provisioning. Surfaced by cloudprovider Create tests. * test: cover Create/Delete/Get/List/GetInstanceTypes with fake-client harness * test: cover Delete idempotency, nil ServerType, and no-compatible-type Create Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: chart injects CLUSTER_NAME, adds probes and metrics/health ports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: NodeClass controller validates firewalls and SSH keys exist Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: stop registering unused LabelLocation as well-known Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: amend spec for fake-client testing decision and env-only cluster config Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: fix lint — golangci-lint v2 (action@v8) + relax go directive golangci-lint v1.64.8 (built with go1.24) refused to lint a module targeting go1.26.3. Upgrade to golangci-lint v2 via action@v8 with a v2-format config (gosimple merged into staticcheck), and let go mod tidy settle the go directive to the dependency floor (1.26.2) instead of the spurious 1.26.3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: resolve golangci-lint v2 findings + latent scheme-registration bug - register HCloudNodeClass into the client-go global scheme in init() so the operator manager (built from scheme.Scheme) can watch it at runtime; this also replaces the deprecated controller-runtime scheme.Builder (SA1019) - applyAvailability: construct a fresh InstanceType instead of copying *it, fixing govet copylocks (InstanceType embeds sync.Once) - drop unused abs() test helper (unused); simplify promoted-field selector (QF1008) - gofmt Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: regenerate deepcopy to canonical controller-gen output (unaliased import) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef683bf commit 579fba4

34 files changed

Lines changed: 3415 additions & 153 deletions

.github/workflows/ci.yaml

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,34 @@ jobs:
1515
- uses: actions/checkout@v4
1616
- uses: actions/setup-go@v5
1717
with:
18-
go-version: "1.23"
19-
- run: go test -race -count=1 ./...
18+
go-version: "1.26"
19+
- run: make test
2020

2121
lint:
2222
runs-on: ubuntu-latest
2323
steps:
2424
- uses: actions/checkout@v4
2525
- uses: actions/setup-go@v5
2626
with:
27-
go-version: "1.23"
28-
- uses: golangci/golangci-lint-action@v6
27+
go-version: "1.26"
28+
- uses: golangci/golangci-lint-action@v8
2929
with:
3030
version: latest
31+
32+
generate:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- uses: actions/checkout@v4
36+
- uses: actions/setup-go@v5
37+
with:
38+
go-version: "1.26"
39+
- run: make generate-verify
40+
41+
controllers:
42+
runs-on: ubuntu-latest
43+
steps:
44+
- uses: actions/checkout@v4
45+
- uses: actions/setup-go@v5
46+
with:
47+
go-version: "1.26"
48+
- run: go test -race -count=1 ./pkg/controllers/...

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616

1717
- uses: actions/setup-go@v5
1818
with:
19-
go-version: "1.23"
19+
go-version: "1.26"
2020

2121
- uses: docker/setup-buildx-action@v3
2222

.golangci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
version: "2"
2+
13
linters:
24
enable:
35
- errcheck
46
- govet
7+
- ineffassign
58
- staticcheck
69
- unused
7-
- gosimple
8-
- ineffassign

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM golang:1.23-alpine AS builder
1+
FROM golang:1.26-alpine AS builder
22
WORKDIR /app
33
COPY go.mod go.sum ./
44
RUN go mod download

Makefile

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
.PHONY: build test lint generate docker-build
1+
.PHONY: build test lint generate generate-verify docker-build test-envtest
22

3-
BINARY := karpenter-provider-hetzner
4-
IMAGE := ghcr.io/paperclipinc/karpenter-provider-hetzner
5-
TAG ?= latest
3+
BINARY := karpenter-provider-hetzner
4+
IMAGE := ghcr.io/paperclipinc/karpenter-provider-hetzner
5+
TAG ?= latest
6+
CONTROLLER_GEN := go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0
7+
ENVTEST := go run sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
8+
ENVTEST_K8S_VERSION ?= 1.34.0
69

710
build:
811
go build -o bin/$(BINARY) ./cmd/controller
@@ -14,8 +17,19 @@ lint:
1417
golangci-lint run ./...
1518

1619
generate:
17-
controller-gen object paths="./pkg/apis/..."
18-
controller-gen crd paths="./pkg/apis/..." output:crd:dir=charts/karpenter-provider-hetzner/crds
20+
$(CONTROLLER_GEN) object paths="./pkg/apis/..."
21+
$(CONTROLLER_GEN) crd paths="./pkg/apis/..." output:crd:dir=charts/karpenter-provider-hetzner/crds
22+
23+
generate-verify: generate
24+
@if [ -n "$$(git status --porcelain pkg/apis charts/karpenter-provider-hetzner/crds)" ]; then \
25+
echo "generated files are out of date; run 'make generate' and commit"; \
26+
git --no-pager diff -- pkg/apis charts/karpenter-provider-hetzner/crds; \
27+
exit 1; \
28+
fi
29+
30+
test-envtest:
31+
KUBEBUILDER_ASSETS="$$($(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" \
32+
go test -race -count=1 ./pkg/controllers/...
1933

2034
docker-build:
2135
docker build -t $(IMAGE):$(TAG) .
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
---
2+
apiVersion: apiextensions.k8s.io/v1
3+
kind: CustomResourceDefinition
4+
metadata:
5+
annotations:
6+
controller-gen.kubebuilder.io/version: v0.19.0
7+
name: hcloudnodeclasses.karpenter.hetzner.cloud
8+
spec:
9+
group: karpenter.hetzner.cloud
10+
names:
11+
categories:
12+
- karpenter
13+
kind: HCloudNodeClass
14+
listKind: HCloudNodeClassList
15+
plural: hcloudnodeclasses
16+
shortNames:
17+
- hcnc
18+
singular: hcloudnodeclass
19+
scope: Namespaced
20+
versions:
21+
- additionalPrinterColumns:
22+
- jsonPath: .status.conditions[?(@.type=='Ready')].status
23+
name: Ready
24+
type: string
25+
name: v1alpha1
26+
schema:
27+
openAPIV3Schema:
28+
properties:
29+
apiVersion:
30+
description: |-
31+
APIVersion defines the versioned schema of this representation of an object.
32+
Servers should convert recognized schemas to the latest internal value, and
33+
may reject unrecognized values.
34+
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
35+
type: string
36+
kind:
37+
description: |-
38+
Kind is a string value representing the REST resource this object represents.
39+
Servers may infer this from the endpoint the client submits requests to.
40+
Cannot be updated.
41+
In CamelCase.
42+
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
43+
type: string
44+
metadata:
45+
type: object
46+
spec:
47+
properties:
48+
enablePublicIPv4:
49+
default: true
50+
description: |-
51+
EnablePublicIPv4 controls whether created servers get a public IPv4.
52+
Defaults to true (Hetzner's default). Set false on private-network
53+
clusters to avoid the primary-IPv4 charge.
54+
type: boolean
55+
enablePublicIPv6:
56+
default: true
57+
description: |-
58+
EnablePublicIPv6 controls whether created servers get a public IPv6.
59+
Defaults to true. Set false to drop the public IPv6 as well.
60+
type: boolean
61+
firewallIDs:
62+
items:
63+
format: int64
64+
type: integer
65+
type: array
66+
imageSelector:
67+
properties:
68+
family:
69+
enum:
70+
- talos
71+
- ubuntu
72+
type: string
73+
version:
74+
type: string
75+
required:
76+
- family
77+
type: object
78+
labels:
79+
additionalProperties:
80+
type: string
81+
type: object
82+
locations:
83+
items:
84+
type: string
85+
minItems: 1
86+
type: array
87+
networkID:
88+
format: int64
89+
minimum: 1
90+
type: integer
91+
placementGroupStrategy:
92+
default: spread
93+
enum:
94+
- spread
95+
- none
96+
type: string
97+
sshKeyIDs:
98+
items:
99+
format: int64
100+
type: integer
101+
type: array
102+
userData:
103+
type: string
104+
required:
105+
- imageSelector
106+
- locations
107+
- networkID
108+
type: object
109+
status:
110+
properties:
111+
conditions:
112+
items:
113+
description: Condition aliases the upstream type and adds additional
114+
helper methods
115+
properties:
116+
lastTransitionTime:
117+
description: |-
118+
lastTransitionTime is the last time the condition transitioned from one status to another.
119+
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
120+
format: date-time
121+
type: string
122+
message:
123+
description: |-
124+
message is a human readable message indicating details about the transition.
125+
This may be an empty string.
126+
maxLength: 32768
127+
type: string
128+
observedGeneration:
129+
description: |-
130+
observedGeneration represents the .metadata.generation that the condition was set based upon.
131+
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
132+
with respect to the current state of the instance.
133+
format: int64
134+
minimum: 0
135+
type: integer
136+
reason:
137+
description: |-
138+
reason contains a programmatic identifier indicating the reason for the condition's last transition.
139+
Producers of specific condition types may define expected values and meanings for this field,
140+
and whether the values are considered a guaranteed API.
141+
The value should be a CamelCase string.
142+
This field may not be empty.
143+
maxLength: 1024
144+
minLength: 1
145+
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
146+
type: string
147+
status:
148+
description: status of the condition, one of True, False, Unknown.
149+
enum:
150+
- "True"
151+
- "False"
152+
- Unknown
153+
type: string
154+
type:
155+
description: type of condition in CamelCase or in foo.example.com/CamelCase.
156+
maxLength: 316
157+
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
158+
type: string
159+
required:
160+
- lastTransitionTime
161+
- message
162+
- reason
163+
- status
164+
- type
165+
type: object
166+
type: array
167+
resolvedImages:
168+
items:
169+
properties:
170+
architecture:
171+
type: string
172+
imageID:
173+
format: int64
174+
type: integer
175+
required:
176+
- architecture
177+
- imageID
178+
type: object
179+
type: array
180+
type: object
181+
type: object
182+
served: true
183+
storage: true
184+
subresources:
185+
status: {}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
{{- if not .Values.clusterName }}
2+
{{- fail "clusterName is required: set --set clusterName=<your-cluster>" }}
3+
{{- end }}
14
apiVersion: apps/v1
25
kind: Deployment
36
metadata:
@@ -28,6 +31,29 @@ spec:
2831
secretKeyRef:
2932
name: {{ .Values.auth.secretRef.name }}
3033
key: {{ .Values.auth.secretRef.key }}
34+
- name: CLUSTER_NAME
35+
value: {{ .Values.clusterName | quote }}
36+
- name: METRICS_PORT
37+
value: {{ .Values.metrics.port | quote }}
38+
- name: HEALTH_PROBE_PORT
39+
value: {{ .Values.healthProbe.port | quote }}
40+
ports:
41+
- name: http-metrics
42+
containerPort: {{ .Values.metrics.port }}
43+
- name: http-health
44+
containerPort: {{ .Values.healthProbe.port }}
45+
livenessProbe:
46+
httpGet:
47+
path: /healthz
48+
port: http-health
49+
initialDelaySeconds: 30
50+
periodSeconds: 30
51+
readinessProbe:
52+
httpGet:
53+
path: /readyz
54+
port: http-health
55+
initialDelaySeconds: 5
56+
periodSeconds: 10
3157
resources:
3258
{{- toYaml .Values.resources | nindent 12 }}
3359
securityContext:

charts/karpenter-provider-hetzner/values.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,11 @@ resources:
2121
limits:
2222
cpu: 500m
2323
memory: 256Mi
24+
25+
# Required: scopes managed servers so multiple clusters can share one Hetzner project.
26+
clusterName: ""
27+
28+
metrics:
29+
port: 8080
30+
healthProbe:
31+
port: 8081

cmd/controller/main.go

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
_ "github.com/paperclipinc/karpenter-provider-hetzner/pkg/apis/v1alpha1"
1616

1717
hetznercp "github.com/paperclipinc/karpenter-provider-hetzner/pkg/cloudprovider"
18+
"github.com/paperclipinc/karpenter-provider-hetzner/pkg/controllers/nodeclass"
1819
hetznerop "github.com/paperclipinc/karpenter-provider-hetzner/pkg/operator"
1920
"github.com/paperclipinc/karpenter-provider-hetzner/pkg/providers/imagefamily"
2021
"github.com/paperclipinc/karpenter-provider-hetzner/pkg/providers/instance"
@@ -31,8 +32,14 @@ func main() {
3132
return
3233
}
3334

35+
cfg, err := hetznerop.LoadConfig()
36+
if err != nil {
37+
log.FromContext(ctx).Error(err, "failed to load config")
38+
return
39+
}
40+
3441
// Create the three providers.
35-
instanceProvider := instance.NewProvider(&hcloudClient.Server)
42+
instanceProvider := instance.NewProviderWithWaiter(&hcloudClient.Server, cfg.ClusterName, &hcloudClient.Action)
3643
typeProvider := instancetype.NewProvider(&hcloudClient.ServerType)
3744
imageProvider := imagefamily.NewProvider(&hcloudClient.Image)
3845

@@ -50,16 +57,22 @@ func main() {
5057
// Create cluster state.
5158
clusterState := state.NewCluster(op.Clock, op.GetClient(), cloudProvider)
5259

60+
// Our NodeClass status controller (network + image validation, Ready).
61+
nodeClassController := nodeclass.NewController(op.GetClient(), &hcloudClient.Network, &hcloudClient.Firewall, &hcloudClient.SSHKey, imageProvider)
62+
5363
// Wire and start all controllers.
54-
op.WithControllers(ctx, controllers.NewControllers(
55-
ctx,
56-
op.Manager,
57-
op.Clock,
58-
op.GetClient(),
59-
op.EventRecorder,
60-
cloudProvider,
61-
baseCloudProvider,
62-
clusterState,
63-
op.InstanceTypeStore,
64+
op.WithControllers(ctx, append(
65+
controllers.NewControllers(
66+
ctx,
67+
op.Manager,
68+
op.Clock,
69+
op.GetClient(),
70+
op.EventRecorder,
71+
cloudProvider,
72+
baseCloudProvider,
73+
clusterState,
74+
op.InstanceTypeStore,
75+
),
76+
nodeClassController,
6477
)...).Start(ctx)
6578
}

0 commit comments

Comments
 (0)