Skip to content

Commit f8c136b

Browse files
mitjaclaude
andcommitted
Support custom Hetzner Cloud snapshot images via label resolution
Hetzner snapshots have name=null and carry labels, so they cannot be referenced by the existing name-based image lookup. Extract the image resolution into resolveImage() and add a third fallback: resolve a snapshot via the label "gardener.cloud/image-name=<imageName>", picking the newest by Created when several match. Resolution order: system image by name -> name via AllWithOpts -> snapshot via label -> error. Adds unit tests for the resolver (system-image-by-name, snapshot-by-label newest-wins, no-match error) and extends the CreateMachine table. Adds a tag-triggered release workflow pushing ghcr.io/mitja/machine-controller-manager-provider-hcloud and removes the upstream release workflow that pushed to ghcr.io/23technologies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 07ddddc commit f8c136b

7 files changed

Lines changed: 275 additions & 65 deletions

File tree

.github/workflows/on-release-publish.yml

Lines changed: 0 additions & 51 deletions
This file was deleted.

.github/workflows/release.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
packages: write
12+
13+
env:
14+
REGISTRY: ghcr.io
15+
IMAGE_NAME: mitja/machine-controller-manager-provider-hcloud
16+
17+
jobs:
18+
release:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v6
23+
24+
- name: Set up Go
25+
uses: actions/setup-go@v6
26+
with:
27+
go-version: "1.24"
28+
check-latest: true
29+
30+
- name: Verify build and tests
31+
run: |
32+
go vet ./...
33+
go test ./...
34+
35+
- name: Update VERSION file
36+
run: echo "${{ github.ref_name }}" > VERSION
37+
38+
- name: Log in to GHCR
39+
uses: docker/login-action@v4
40+
with:
41+
registry: ${{ env.REGISTRY }}
42+
username: ${{ github.actor }}
43+
password: ${{ secrets.GITHUB_TOKEN }}
44+
45+
- name: Set up Docker Buildx
46+
uses: docker/setup-buildx-action@v4
47+
48+
- name: Build and push image
49+
uses: docker/build-push-action@v7
50+
with:
51+
context: .
52+
target: machine-controller
53+
platforms: linux/amd64
54+
push: true
55+
tags: |
56+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
57+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
58+
59+
- name: Create GitHub release
60+
uses: softprops/action-gh-release@v3
61+
with:
62+
tag_name: ${{ github.ref_name }}
63+
generate_release_notes: true

pkg/hcloud/apis/mock/machine_controller.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,39 @@ const (
5252
},
5353
"deprecated": "2018-02-28T00:00:00+00:00",
5454
"labels": {}
55+
}
56+
`
57+
// jsonSnapshotImagesData represents two custom snapshots (name=null) that
58+
// both carry the label "gardener.cloud/image-name=gardenlinux-snapshot".
59+
// The newest one (by created) has id 4711.
60+
jsonSnapshotImagesData = `
61+
{
62+
"id": 100,
63+
"type": "snapshot",
64+
"status": "available",
65+
"name": null,
66+
"description": "older gardenlinux snapshot",
67+
"image_size": 2.3,
68+
"disk_size": 10,
69+
"created": "2020-01-01T00:00:00+00:00",
70+
"os_flavor": "ubuntu",
71+
"rapid_deploy": false,
72+
"protection": { "delete": false },
73+
"labels": { "gardener.cloud/image-name": "gardenlinux-snapshot" }
74+
},
75+
{
76+
"id": 4711,
77+
"type": "snapshot",
78+
"status": "available",
79+
"name": null,
80+
"description": "newer gardenlinux snapshot",
81+
"image_size": 2.3,
82+
"disk_size": 10,
83+
"created": "2023-06-01T00:00:00+00:00",
84+
"os_flavor": "ubuntu",
85+
"rapid_deploy": false,
86+
"protection": { "delete": false },
87+
"labels": { "gardener.cloud/image-name": "gardenlinux-snapshot" }
5588
}
5689
`
5790
jsonServerDataTemplate = `
@@ -157,6 +190,15 @@ const (
157190
TestServerID = 42
158191
TestServerNameTemplate = "machine-%d"
159192
testServersLabelSelector = "mcm.gardener.cloud/role=node,topology.kubernetes.io/zone=hel1-dc2"
193+
194+
// TestSnapshotImageName is the value of the "gardener.cloud/image-name"
195+
// label used to resolve a custom snapshot in tests.
196+
TestSnapshotImageName = "gardenlinux-snapshot"
197+
// TestSnapshotLabelSelector is the label selector the provider sends to
198+
// resolve a snapshot by name.
199+
TestSnapshotLabelSelector = "gardener.cloud/image-name=gardenlinux-snapshot"
200+
// TestNewestSnapshotImageID is the id of the newest matching snapshot.
201+
TestNewestSnapshotImageID = 4711
160202
)
161203

162204
// ManipulateMachine changes given machine data.
@@ -287,6 +329,15 @@ func SetupImagesEndpointOnMux(mux *http.ServeMux) {
287329
}
288330
}
289331

332+
// Custom snapshots are resolved by the "gardener.cloud/image-name"
333+
// label selector. Return the two matching snapshots so the provider
334+
// can pick the newest one.
335+
if queryParams.Get("label_selector") == TestSnapshotLabelSelector {
336+
if _, err := res.Write([]byte(jsonSnapshotImagesData)); err != nil {
337+
panic(err)
338+
}
339+
}
340+
290341
if _, err := res.Write([]byte(`
291342
]
292343
}

pkg/hcloud/apis/mock/provider_spec.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ const (
2929
TestSSHFingerprint = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff"
3030
TestZone = "hel1-dc2"
3131
TestInvalidProviderSpec = "{\"test\":\"invalid\"}"
32+
33+
// TestSnapshotProviderSpec references a custom snapshot by the value of its
34+
// "gardener.cloud/image-name" label instead of a system image name.
35+
TestSnapshotProviderSpec = "{\"cluster\":\"xyz\",\"zone\":\"hel1-dc2\",\"imageName\":\"gardenlinux-snapshot\",\"serverType\":\"cx11-ceph\",\"placementGroupID\":\"42\",\"sshFingerprint\":\"00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff\"}"
36+
// TestUnknownImageProviderSpec references an image name that matches neither
37+
// a system image nor a snapshot label.
38+
TestUnknownImageProviderSpec = "{\"cluster\":\"xyz\",\"zone\":\"hel1-dc2\",\"imageName\":\"does-not-exist\",\"serverType\":\"cx11-ceph\",\"placementGroupID\":\"42\",\"sshFingerprint\":\"00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff\"}"
3239
)
3340

3441
// ManipulateProviderSpec changes given provider specification.

pkg/hcloud/image_resolver_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
Copyright (c) 2021 SAP SE or an SAP affiliate company. All rights reserved.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package hcloud contains the HCloud provider specific implementations to manage machines
18+
package hcloud
19+
20+
import (
21+
"context"
22+
23+
. "github.com/onsi/ginkgo/v2"
24+
. "github.com/onsi/gomega"
25+
26+
"github.com/23technologies/machine-controller-manager-provider-hcloud/pkg/hcloud/apis/mock"
27+
)
28+
29+
var _ = Describe("resolveImage", func() {
30+
var mockTestEnv mock.MockTestEnv
31+
32+
BeforeEach(func() {
33+
mockTestEnv = mock.NewMockTestEnv()
34+
mock.SetupImagesEndpointOnMux(mockTestEnv.Mux)
35+
})
36+
37+
AfterEach(func() {
38+
mockTestEnv.Teardown()
39+
})
40+
41+
It("resolves a system image by name", func() {
42+
image, err := resolveImage(context.Background(), mockTestEnv.Client, mock.TestImageName)
43+
Expect(err).NotTo(HaveOccurred())
44+
Expect(image).NotTo(BeNil())
45+
Expect(image.Name).To(Equal(mock.TestImageName))
46+
})
47+
48+
It("resolves a custom snapshot by label and picks the newest by Created", func() {
49+
image, err := resolveImage(context.Background(), mockTestEnv.Client, mock.TestSnapshotImageName)
50+
Expect(err).NotTo(HaveOccurred())
51+
Expect(image).NotTo(BeNil())
52+
// The snapshot has an empty Name; it is resolved purely via its label.
53+
Expect(image.Name).To(BeEmpty())
54+
Expect(image.ID).To(Equal(mock.TestNewestSnapshotImageID))
55+
Expect(image.Labels).To(HaveKeyWithValue(imageNameLabelKey, mock.TestSnapshotImageName))
56+
})
57+
58+
It("returns an error when neither a system image nor a snapshot matches", func() {
59+
image, err := resolveImage(context.Background(), mockTestEnv.Client, "does-not-exist")
60+
Expect(err).To(HaveOccurred())
61+
Expect(image).To(BeNil())
62+
Expect(err.Error()).To(ContainSubstring("not found (by name or label gardener.cloud/image-name)"))
63+
})
64+
})

pkg/hcloud/machine_controller.go

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"encoding/base64"
2323
"fmt"
2424
"net/url"
25+
"sort"
2526
"strconv"
2627

2728
"github.com/gardener/machine-controller-manager/pkg/util/provider/driver"
@@ -34,6 +35,65 @@ import (
3435
"github.com/23technologies/machine-controller-manager-provider-hcloud/pkg/hcloud/apis/transcoder"
3536
)
3637

38+
// imageNameLabelKey is the Hetzner Cloud label key used to reference a custom
39+
// snapshot image by a stable, human-readable name. Hetzner snapshots have an
40+
// empty (null) Name field - their custom text lives in Description and they
41+
// carry labels - so a snapshot cannot be looked up by name. Gardener tags the
42+
// snapshot with this label (value == the CloudProfileConfig
43+
// machineImages[].versions[].imageName) so the provider can resolve it.
44+
const imageNameLabelKey = "gardener.cloud/image-name"
45+
46+
// resolveImage resolves a Hetzner Cloud image from the given imageName.
47+
//
48+
// The resolution order is:
49+
// 1. System image by name (client.Image.GetByName)
50+
// 2. Image by name via AllWithOpts (covers deprecated images)
51+
// 3. Custom snapshot via the label "gardener.cloud/image-name=<imageName>";
52+
// if multiple snapshots match, the newest one (by Created) is chosen.
53+
//
54+
// PARAMETERS
55+
// ctx context.Context Execution context
56+
// client *hcloud.Client Hetzner Cloud client
57+
// imageName string Image name or label value to resolve
58+
func resolveImage(ctx context.Context, client *hcloud.Client, imageName string) (*hcloud.Image, error) {
59+
// 1. System image by name.
60+
image, _, err := client.Image.GetByName(ctx, imageName)
61+
if err != nil {
62+
return nil, err
63+
}
64+
if image != nil {
65+
return image, nil
66+
}
67+
68+
// 2. Image by name via AllWithOpts (covers deprecated images).
69+
images, err := client.Image.AllWithOpts(ctx, hcloud.ImageListOpts{Name: imageName, IncludeDeprecated: true})
70+
if err != nil {
71+
return nil, err
72+
}
73+
if len(images) > 0 {
74+
return images[0], nil
75+
}
76+
77+
// 3. Custom snapshot via label "gardener.cloud/image-name=<imageName>".
78+
snapshots, err := client.Image.AllWithOpts(ctx, hcloud.ImageListOpts{
79+
ListOpts: hcloud.ListOpts{LabelSelector: imageNameLabelKey + "=" + imageName},
80+
Type: []hcloud.ImageType{hcloud.ImageTypeSnapshot},
81+
IncludeDeprecated: true,
82+
})
83+
if err != nil {
84+
return nil, err
85+
}
86+
if len(snapshots) > 0 {
87+
// Pick the newest snapshot by Created timestamp.
88+
sort.Slice(snapshots, func(i, j int) bool {
89+
return snapshots[i].Created.After(snapshots[j].Created)
90+
})
91+
return snapshots[0], nil
92+
}
93+
94+
return nil, fmt.Errorf("Image %s not found (by name or label %s)", imageName, imageNameLabelKey)
95+
}
96+
3797
// CreateMachine handles a machine creation request
3898
//
3999
// PARAMETERS
@@ -102,24 +162,11 @@ func (p *MachineProvider) createMachine(ctx context.Context, req *driver.CreateM
102162
imageName := providerSpec.ImageName
103163
userDataBase64Enc := base64.StdEncoding.EncodeToString(userData)
104164

105-
image, _, err := client.Image.GetByName(ctx, imageName)
165+
image, err := resolveImage(ctx, client, imageName)
106166
if err != nil {
107167
return nil, status.Error(codes.InvalidArgument, err.Error())
108168
}
109169

110-
if image == nil {
111-
images, err := client.Image.AllWithOpts(ctx, hcloud.ImageListOpts{Name: imageName, IncludeDeprecated: true})
112-
if err != nil {
113-
return nil, status.Error(codes.InvalidArgument, err.Error())
114-
}
115-
116-
if len(images) == 0 {
117-
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("Image %s not found", imageName))
118-
} else {
119-
image = images[0]
120-
}
121-
}
122-
123170
region := apis.GetRegionFromZone(providerSpec.Zone)
124171
startAfterCreate := false
125172
zone := providerSpec.Zone

0 commit comments

Comments
 (0)