Skip to content

Commit 8ab0b5e

Browse files
authored
🌱 Hcloud: Provision hcloud machines with custom command (instead of Snapshots) (#1647)
1 parent 2507017 commit 8ab0b5e

28 files changed

+1820
-137
lines changed

.mockery.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# This config was choosen, so that the output matches to old structure (pre config file .mockery.yaml).
1+
# This config was chosen, so that the output matches to old structure (pre config file .mockery.yaml).
22
# If you are here to copy this config to a new project, then it might
33
# make sense to choose a structure which needs less config by using
44
# the default values of Mockery.

api/v1beta1/hcloudmachine_types.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,33 @@ type HCloudMachineSpec struct {
4646
// ImageName is the reference to the Machine Image from which to create the machine instance.
4747
// It can reference an image uploaded to Hetzner API in two ways: either directly as the name of an image or as the label of an image.
4848
// +kubebuilder:validation:MinLength=1
49-
ImageName string `json:"imageName"`
49+
// +kubebuilder:validation:Optional
50+
// +optional
51+
ImageName string `json:"imageName,omitempty"`
52+
53+
// ImageURL gets used for installing custom node images. If that field is set, the controller
54+
// boots a new HCloud machine into rescue mode. Then the script provided by
55+
// --hcloud-image-url-command (which you need to provide to the controller binary) will be
56+
// copied into the rescue system and executed.
57+
//
58+
// The controller uses url.ParseRequestURI (Go function) to validate the URL.
59+
//
60+
// It is up to the script to provision the disk of the hcloud machine accordingly. The process
61+
// is considered successful if the last line in the output contains
62+
// IMAGE_URL_DONE. If the script terminates with a different last line, then
63+
// the process is considered to have failed.
64+
//
65+
// A Kubernetes event will be created in both (success, failure) cases containing the output
66+
// (stdout and stderr) of the script. If the script takes longer than 7 minutes, the
67+
// controller cancels the provisioning.
68+
//
69+
// Docs: https://syself.com/docs/caph/developers/image-url-command
70+
//
71+
// ImageURL is mutually exclusive to "ImageName".
72+
// +kubebuilder:validation:MinLength=1
73+
// +kubebuilder:validation:Optional
74+
// +optional
75+
ImageURL string `json:"imageURL,omitempty"`
5076

5177
// SSHKeys define machine-specific SSH keys and override cluster-wide SSH keys.
5278
// +optional
@@ -99,16 +125,35 @@ type HCloudMachineStatus struct {
99125

100126
// BootState indicates the current state during provisioning.
101127
//
102-
// The states will be:
103-
// "" -> BootToRealOS -> OperatingSystemRunning
128+
// If Spec.ImageName is set the states will be:
129+
// 1. BootingToRealOS
130+
// 2. OperatingSystemRunning
104131
//
132+
// If Spec.ImageURL is set the states will be:
133+
// 1. Initializing
134+
// 2. EnablingRescue
135+
// 3. BootingToRescue
136+
// 4. RunningImageCommand
137+
// 5. BootingToRealOS
138+
// 6. OperatingSystemRunning
139+
105140
// +optional
106141
BootState HCloudBootState `json:"bootState"`
107142

108143
// BootStateSince is the timestamp of the last change to BootState. It is used to timeout
109144
// provisioning if a state takes too long.
110145
// +optional
111146
BootStateSince metav1.Time `json:"bootStateSince,omitzero"`
147+
148+
// ExternalIDs contains temporary data during the provisioning process
149+
ExternalIDs HCloudMachineStatusExternalIDs `json:"externalIDs,omitempty"`
150+
}
151+
152+
// HCloudMachineStatusExternalIDs holds temporary data during the provisioning process.
153+
type HCloudMachineStatusExternalIDs struct {
154+
// ActionIDEnableRescueSystem is the hcloud API Action result of EnableRescueSystem.
155+
// +optional
156+
ActionIDEnableRescueSystem int64 `json:"actionIdEnableRescueSystem,omitzero"`
112157
}
113158

114159
// HCloudMachine is the Schema for the hcloudmachines API.

api/v1beta1/hcloudmachine_validation.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package v1beta1
1818

1919
import (
20+
"net/url"
2021
"reflect"
2122

2223
"k8s.io/apimachinery/pkg/util/validation/field"
@@ -38,6 +39,13 @@ func validateHCloudMachineSpecUpdate(oldSpec, newSpec HCloudMachineSpec) field.E
3839
)
3940
}
4041

42+
// ImageURL is immutable
43+
if !reflect.DeepEqual(oldSpec.ImageURL, newSpec.ImageURL) {
44+
allErrs = append(allErrs,
45+
field.Invalid(field.NewPath("spec", "imageURL"), newSpec.ImageURL, "field is immutable"),
46+
)
47+
}
48+
4149
// SSHKeys is immutable
4250
if !reflect.DeepEqual(oldSpec.SSHKeys, newSpec.SSHKeys) {
4351
allErrs = append(allErrs,
@@ -52,5 +60,30 @@ func validateHCloudMachineSpecUpdate(oldSpec, newSpec HCloudMachineSpec) field.E
5260
)
5361
}
5462

63+
allErrs = append(allErrs, validateHCloudMachineSpec(newSpec)...)
64+
65+
return allErrs
66+
}
67+
68+
func validateHCloudMachineSpec(spec HCloudMachineSpec) field.ErrorList {
69+
var allErrs field.ErrorList
70+
if spec.ImageName != "" && spec.ImageURL != "" {
71+
allErrs = append(allErrs,
72+
field.Invalid(field.NewPath("spec", "imageName"), spec.ImageName, "imageName and imageURL are mutually exclusive"))
73+
}
74+
75+
if spec.ImageName == "" && spec.ImageURL == "" {
76+
allErrs = append(allErrs,
77+
field.Invalid(field.NewPath("spec", "imageName"), spec.ImageName, "imageName and imageURL empty. One of these attributes must be set"))
78+
}
79+
80+
if spec.ImageURL != "" {
81+
_, err := url.ParseRequestURI(spec.ImageURL)
82+
if err != nil {
83+
allErrs = append(allErrs,
84+
field.Invalid(field.NewPath("spec", "imageURL"), spec.ImageURL, err.Error()))
85+
}
86+
}
87+
5588
return allErrs
5689
}

api/v1beta1/hcloudmachine_validation_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ limitations under the License.
1717
package v1beta1
1818

1919
import (
20+
"strings"
2021
"testing"
2122

2223
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
2325
"k8s.io/apimachinery/pkg/util/validation/field"
2426
)
2527

@@ -60,6 +62,18 @@ func TestValidateHCloudMachineSpecUpdate(t *testing.T) {
6062
},
6163
want: field.Invalid(field.NewPath("spec", "imageName"), "centos-7", "field is immutable"),
6264
},
65+
{
66+
name: "Immutable ImageURL",
67+
args: args{
68+
oldSpec: HCloudMachineSpec{
69+
ImageURL: "oci://ghcr.io/example/foo:v1",
70+
},
71+
newSpec: HCloudMachineSpec{
72+
ImageURL: "oci://ghcr.io/example/foo:v2",
73+
},
74+
},
75+
want: field.Invalid(field.NewPath("spec", "imageURL"), "oci://ghcr.io/example/foo:v2", "field is immutable"),
76+
},
6377
{
6478
name: "Immutable SSHKeys",
6579
args: args{
@@ -152,3 +166,24 @@ func TestValidateHCloudMachineSpecUpdate(t *testing.T) {
152166
func createPlacementGroupName(name string) *string {
153167
return &name
154168
}
169+
170+
func TestValidateHCloudMachineSpec(t *testing.T) {
171+
allErrs := validateHCloudMachineSpec(HCloudMachineSpec{
172+
ImageURL: "not-a-valid-url",
173+
})
174+
require.Equal(t, `spec.imageURL: Invalid value: "not-a-valid-url": parse "not-a-valid-url": invalid URI for request`, errorsToString(allErrs))
175+
176+
allErrs = validateHCloudMachineSpec(HCloudMachineSpec{
177+
ImageName: "foo-name",
178+
ImageURL: "oci://ghcr.io/example/foo:v1",
179+
})
180+
require.Equal(t, `spec.imageName: Invalid value: "foo-name": imageName and imageURL are mutually exclusive`, errorsToString(allErrs))
181+
}
182+
183+
func errorsToString(allErrs field.ErrorList) string {
184+
s := make([]string, 0, len(allErrs))
185+
for _, err := range allErrs {
186+
s = append(s, err.Error())
187+
}
188+
return strings.Join(s, "\n")
189+
}

api/v1beta1/hcloudmachine_webhook.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import (
2222

2323
apierrors "k8s.io/apimachinery/pkg/api/errors"
2424
"k8s.io/apimachinery/pkg/runtime"
25-
"k8s.io/apimachinery/pkg/util/validation/field"
2625
ctrl "sigs.k8s.io/controller-runtime"
2726
"sigs.k8s.io/controller-runtime/pkg/webhook"
2827
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -83,7 +82,8 @@ func (*hcloudMachineWebhook) ValidateCreate(_ context.Context, obj runtime.Objec
8382
}
8483

8584
hcloudmachinelog.V(1).Info("validate create", "name", r.Name)
86-
var allErrs field.ErrorList
85+
86+
allErrs := validateHCloudMachineSpec(r.Spec)
8787

8888
return nil, aggregateObjErrors(r.GroupVersionKind().GroupKind(), r.Name, allErrs)
8989
}

api/v1beta1/types.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,24 @@ const (
271271
// HCloudBootStateUnset is the initial state when the boot state has not been set yet.
272272
HCloudBootStateUnset HCloudBootState = ""
273273

274-
// HCloudBootStateBootToRealOS indicates that the server is booting the operating system.
275-
HCloudBootStateBootToRealOS HCloudBootState = "BootToRealOS"
274+
// HCloudBootStateInitializing indicates that the controller waits for PreRescueOS.
275+
// When it is available, then the rescue system gets enabled.
276+
HCloudBootStateInitializing HCloudBootState = "Initializing"
277+
278+
// HCloudBootStateEnablingRescue indicates that the controller waits for the rescue system to be enabled. Then the server gets booted into the rescue system.
279+
HCloudBootStateEnablingRescue HCloudBootState = "EnablingRescue"
280+
281+
// HCloudBootStateBootingToRescue indicates that the controller
282+
// waits for the rescue system to be reachable. Then it starts the image-url-command.
283+
HCloudBootStateBootingToRescue HCloudBootState = "BootingToRescue"
284+
285+
// HCloudBootStateRunningImageCommand indicates the controller waits for the
286+
// image-url-command, and then switches BootState to BootingToRealOS (no additional reboot gets
287+
// done).
288+
HCloudBootStateRunningImageCommand HCloudBootState = "RunningImageCommand"
289+
290+
// HCloudBootStateBootingToRealOS indicates that the server is booting the operating system.
291+
HCloudBootStateBootingToRealOS HCloudBootState = "BootingToRealOS"
276292

277293
// HCloudBootStateOperatingSystemRunning indicates that the server is successfully running.
278294
HCloudBootStateOperatingSystemRunning HCloudBootState = "OperatingSystemRunning"

api/v1beta1/zz_generated.deepcopy.go

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/infrastructure.cluster.x-k8s.io_hcloudmachines.yaml

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,29 @@ spec:
7272
It can reference an image uploaded to Hetzner API in two ways: either directly as the name of an image or as the label of an image.
7373
minLength: 1
7474
type: string
75+
imageURL:
76+
description: |-
77+
ImageURL gets used for installing custom node images. If that field is set, the controller
78+
boots a new HCloud machine into rescue mode. Then the script provided by
79+
--hcloud-image-url-command (which you need to provide to the controller binary) will be
80+
copied into the rescue system and executed.
81+
82+
The controller uses url.ParseRequestURI (Go function) to validate the URL.
83+
84+
It is up to the script to provision the disk of the hcloud machine accordingly. The process
85+
is considered successful if the last line in the output contains
86+
IMAGE_URL_DONE. If the script terminates with a different last line, then
87+
the process is considered to have failed.
88+
89+
A Kubernetes event will be created in both (success, failure) cases containing the output
90+
(stdout and stderr) of the script. If the script takes longer than 7 minutes, the
91+
controller cancels the provisioning.
92+
93+
Docs: https://syself.com/docs/caph/developers/image-url-command
94+
95+
ImageURL is mutually exclusive to "ImageName".
96+
minLength: 1
97+
type: string
7598
placementGroupName:
7699
description: PlacementGroupName defines the placement group of the
77100
machine in HCloud API that must reference an existing placement
@@ -157,7 +180,6 @@ spec:
157180
- cx52
158181
type: string
159182
required:
160-
- imageName
161183
- type
162184
type: object
163185
status:
@@ -190,11 +212,7 @@ spec:
190212
type: object
191213
type: array
192214
bootState:
193-
description: |-
194-
BootState indicates the current state during provisioning.
195-
196-
The states will be:
197-
"" -> BootToRealOS -> OperatingSystemRunning
215+
description: HCloudBootState defines the boot state of an HCloud server.
198216
type: string
199217
bootStateSince:
200218
description: |-
@@ -254,6 +272,16 @@ spec:
254272
- type
255273
type: object
256274
type: array
275+
externalIDs:
276+
description: ExternalIDs contains temporary data during the provisioning
277+
process
278+
properties:
279+
actionIdEnableRescueSystem:
280+
description: ActionIDEnableRescueSystem is the hcloud API Action
281+
result of EnableRescueSystem.
282+
format: int64
283+
type: integer
284+
type: object
257285
failureMessage:
258286
description: |-
259287
FailureMessage will be set in the event that there is a terminal problem

config/crd/bases/infrastructure.cluster.x-k8s.io_hcloudmachinetemplates.yaml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ spec:
9999
It can reference an image uploaded to Hetzner API in two ways: either directly as the name of an image or as the label of an image.
100100
minLength: 1
101101
type: string
102+
imageURL:
103+
description: |-
104+
ImageURL gets used for installing custom node images. If that field is set, the controller
105+
boots a new HCloud machine into rescue mode. Then the script provided by
106+
--hcloud-image-url-command (which you need to provide to the controller binary) will be
107+
copied into the rescue system and executed.
108+
109+
The controller uses url.ParseRequestURI (Go function) to validate the URL.
110+
111+
It is up to the script to provision the disk of the hcloud machine accordingly. The process
112+
is considered successful if the last line in the output contains
113+
IMAGE_URL_DONE. If the script terminates with a different last line, then
114+
the process is considered to have failed.
115+
116+
A Kubernetes event will be created in both (success, failure) cases containing the output
117+
(stdout and stderr) of the script. If the script takes longer than 7 minutes, the
118+
controller cancels the provisioning.
119+
120+
Docs: https://syself.com/docs/caph/developers/image-url-command
121+
122+
ImageURL is mutually exclusive to "ImageName".
123+
minLength: 1
124+
type: string
102125
placementGroupName:
103126
description: PlacementGroupName defines the placement group
104127
of the machine in HCloud API that must reference an existing
@@ -184,7 +207,6 @@ spec:
184207
- cx52
185208
type: string
186209
required:
187-
- imageName
188210
- type
189211
type: object
190212
required:

controllers/controllers_suite_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ var _ = BeforeSuite(func() {
8383
Client: testEnv.Manager.GetClient(),
8484
APIReader: testEnv.Manager.GetAPIReader(),
8585
HCloudClientFactory: testEnv.HCloudClientFactory,
86+
SSHClientFactory: testEnv.BaremetalSSHClientFactory,
8687
}).SetupWithManager(ctx, testEnv.Manager, controller.Options{})).To(Succeed())
8788

8889
Expect((&HCloudMachineTemplateReconciler{
@@ -95,7 +96,7 @@ var _ = BeforeSuite(func() {
9596
Client: testEnv.Manager.GetClient(),
9697
APIReader: testEnv.Manager.GetAPIReader(),
9798
RobotClientFactory: testEnv.RobotClientFactory,
98-
SSHClientFactory: testEnv.SSHClientFactory,
99+
SSHClientFactory: testEnv.BaremetalSSHClientFactory,
99100
PreProvisionCommand: "dummy-pre-provision-command",
100101
}).SetupWithManager(ctx, testEnv.Manager, controller.Options{})).To(Succeed())
101102

0 commit comments

Comments
 (0)