Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Image URL to use all building/pushing image targets
IMG ?= controller:dev
# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary.
ENVTEST_K8S_VERSION = 1.34.1
ENVTEST_K8S_VERSION = 1.36.0

# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
ifeq (,$(shell go env GOBIN))
Expand Down Expand Up @@ -179,10 +179,8 @@ MOCKERY ?= go run github.com/vektra/mockery/v2@v2.53.7

## Tool Versions
KUSTOMIZE_VERSION ?= v5.7.1
CONTROLLER_TOOLS_VERSION ?= v0.19.0
# Pinned to a commit since setup-envtest has no tag compatible with Go 1.25; proper tags
# like tools/setup-envtest/v0.24.1 will be available once we're on Go 1.26.
ENVTEST_VERSION ?= 28c991a58709
CONTROLLER_TOOLS_VERSION ?= v0.20.0
ENVTEST_VERSION ?= v0.25.1

.PHONY: kustomize
kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. If wrong version is installed, it will be removed before downloading.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ If you need help with CAPIC, please visit the [#cluster-api-ionoscloud][slack] c

### Go Version

This provider requires **Go 1.26 or newer**. The exact version is specified in `go.mod`.
This provider requires **Go 1.27 or newer**. The exact version is specified in `go.mod`.

### Cluster API Versions

Expand All @@ -51,8 +51,8 @@ This provider's versions are compatible with the following versions of Cluster A
|-----------------------|:--------------------------:|:--------------------------:|:---------------------------:|:-----------------------------------:|:-----------------------------------:|:-----------------------------------:|
| CAPIC v1alpha1 (v0.4) | ✓ | ☓ | ☓ | ☓ | ☓ | ☓ |
| CAPIC v1alpha1 (v0.5) | ✓ | ☓ | ☓ | ☓ | ☓ | ☓ |
| CAPIC v1alpha1 (v0.6) | ✓ | ✓ | ✓ | ✓ | ☓ | ☓ |
| CAPIC v1alpha1 (v0.7) | ☓ | ☓ | ✓ | ✓ | ✓ | ☓ |
| CAPIC v1alpha1 (v0.6) | ✓ | ✓ | ✓ | ☓ | ☓ | ☓ |
| CAPIC v1alpha1 (v0.7) | ☓ | ☓ | ✓ | ☓ | ☓ | ☓ |
| CAPIC v1alpha1 (v0.8) | ☓ | ☓ | ☓ | ✓ | ✓ | ✓ |

> **v0.8 declares the Cluster API `v1beta2` contract and drops `v1beta1`.** It requires a
Expand Down
11 changes: 9 additions & 2 deletions api/v1alpha1/ionoscloudmachine_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,21 @@ const (
// the underlying VM.
MachineProvisionedCondition = "MachineProvisioned"

// WaitingForClusterInfrastructureReason (Severity=Info) indicates that the IonosCloudMachine is currently
// WaitingForClusterInfrastructureReason indicates that the IonosCloudMachine is currently
// waiting for the cluster infrastructure to become ready.
WaitingForClusterInfrastructureReason = "WaitingForClusterInfrastructure"

// WaitingForBootstrapDataReason (Severity=Info) indicates that the bootstrap provider has not yet finished
// WaitingForBootstrapDataReason indicates that the bootstrap provider has not yet finished
// creating the bootstrap data secret and store it in the Cluster API Machine.
WaitingForBootstrapDataReason = "WaitingForBootstrapData"

// MachineProvisioningReason indicates that provisioning of the IonosCloudMachine is in progress.
MachineProvisioningReason = "Provisioning"

// MachineProvisioningFailedReason indicates that the last provisioning attempt failed.
// The condition message carries the error; provisioning is retried.
MachineProvisioningFailedReason = "ProvisioningFailed"

// MachineProvisionedReason documents that the IonosCloudMachine has been successfully provisioned.
MachineProvisionedReason = "Provisioned"

Expand Down
18 changes: 16 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/util/workqueue"
"k8s.io/klog/v2"
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
ipamv1 "sigs.k8s.io/cluster-api/api/ipam/v1beta2"
Expand All @@ -40,6 +41,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

infrav1 "github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1"
iccontroller "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/controller"
Expand Down Expand Up @@ -145,14 +147,14 @@ func main() {
if err = iccontroller.NewIonosCloudClusterReconciler(mgr).SetupWithManager(
ctx,
mgr,
controller.Options{MaxConcurrentReconciles: icClusterConcurrency},
controllerOptions(icClusterConcurrency),
); err != nil {
setupLog.Error(err, errMsgUnableToCreateController, "controller", "IonosCloudCluster")
os.Exit(1)
}
if err = iccontroller.NewIonosCloudMachineReconciler(mgr).SetupWithManager(
mgr,
controller.Options{MaxConcurrentReconciles: icMachineConcurrency},
controllerOptions(icMachineConcurrency),
); err != nil {
setupLog.Error(err, errMsgUnableToCreateController, "controller", "IonosCloudMachine")
os.Exit(1)
Expand Down Expand Up @@ -199,6 +201,18 @@ func setupCRDMigrator(ctx context.Context, mgr ctrl.Manager) error {
}).SetupWithManager(ctx, mgr, controller.Options{MaxConcurrentReconciles: 1})
}

// controllerOptions returns the options for one of the provider's controllers.
// controller-runtime v0.23 defaults to a per-item-only rate limiter when the priority queue is on.
// Keep the classic one (per-item backoff plus a global 10 qps / 100 burst bucket) so shared failures,
// e.g. an IONOS Cloud API outage, can't turn into retry bursts that grow with the number of objects.
// Every call returns a new limiter, so controllers don't share a budget.
func controllerOptions(concurrency int) controller.Options {
return controller.Options{
MaxConcurrentReconciles: concurrency,
RateLimiter: workqueue.DefaultTypedControllerRateLimiter[reconcile.Request](),
}
}

// initFlags parses the command line flags.
func initFlags() {
klog.InitFlags(nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ kind: CustomResourceDefinition
metadata:
annotations:
cluster.x-k8s.io/v1beta2: v1alpha1
controller-gen.kubebuilder.io/version: v0.19.0
controller-gen.kubebuilder.io/version: v0.20.0
name: ionoscloudclusters.infrastructure.cluster.x-k8s.io
spec:
group: infrastructure.cluster.x-k8s.io
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.19.0
controller-gen.kubebuilder.io/version: v0.20.0
name: ionoscloudclustertemplates.infrastructure.cluster.x-k8s.io
spec:
group: infrastructure.cluster.x-k8s.io
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ kind: CustomResourceDefinition
metadata:
annotations:
cluster.x-k8s.io/v1beta2: v1alpha1
controller-gen.kubebuilder.io/version: v0.19.0
controller-gen.kubebuilder.io/version: v0.20.0
name: ionoscloudmachines.infrastructure.cluster.x-k8s.io
spec:
group: infrastructure.cluster.x-k8s.io
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.19.0
controller-gen.kubebuilder.io/version: v0.20.0
name: ionoscloudmachinetemplates.infrastructure.cluster.x-k8s.io
spec:
group: infrastructure.cluster.x-k8s.io
Expand Down
38 changes: 38 additions & 0 deletions internal/controller/ionoscloudmachine_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import (
"sigs.k8s.io/cluster-api/util"
"sigs.k8s.io/cluster-api/util/annotations"
conditions "sigs.k8s.io/cluster-api/util/conditions"
"sigs.k8s.io/cluster-api/util/predicates"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
Expand All @@ -39,6 +41,7 @@ import (
"github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/service/cloud"
"github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/service/k8s"
"github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/util/locker"
"github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/util/ptr"
"github.com/ionos-cloud/cluster-api-provider-ionoscloud/scope"
)

Expand Down Expand Up @@ -183,10 +186,13 @@ func (r *IonosCloudMachineReconciler) reconcileNormal(
{"FinalizeMachineProvisioning", cloudService.FinalizeMachineProvisioning},
}

setProvisioningCondition(machineScope.IonosMachine, nil)

for _, step := range reconcileSequence {
if requeue, err := step.fn(ctx, machineScope); err != nil || requeue {
if err != nil {
err = fmt.Errorf("error in step %s: %w", step.name, err)
setProvisioningCondition(machineScope.IonosMachine, err)
}

return ctrl.Result{RequeueAfter: defaultReconcileDuration}, err
Expand Down Expand Up @@ -328,15 +334,47 @@ func (*IonosCloudMachineReconciler) isInfrastructureReady(ctx context.Context, m
return true
}

// setProvisioningCondition keeps MachineProvisioned in step with the current provisioning attempt, so it
// doesn't keep showing an earlier "waiting" reason. Once provisioned it stays True: a transient API error
// must not mark a running machine as unprovisioned.
func setProvisioningCondition(ionosMachine *infrav1.IonosCloudMachine, err error) {
if ptr.Deref(ionosMachine.Status.Initialization.Provisioned, false) {
return
}
condition := metav1.Condition{
Type: infrav1.MachineProvisionedCondition,
Status: metav1.ConditionFalse,
Reason: infrav1.MachineProvisioningReason,
}
if err != nil {
condition.Reason = infrav1.MachineProvisioningFailedReason
condition.Message = err.Error()
}
conditions.Set(ionosMachine, condition)
}

// SetupWithManager sets up the controller with the Manager.
func (r *IonosCloudMachineReconciler) SetupWithManager(mgr ctrl.Manager, options controller.Options) error {
clusterToIonosCloudMachines, err := util.ClusterToTypedObjectsMapper(r.Client, &infrav1.IonosCloudMachineList{}, mgr.GetScheme())
if err != nil {
return fmt.Errorf("failed to create Cluster to IonosCloudMachines mapper: %w", err)
}

return ctrl.NewControllerManagedBy(mgr).
WithOptions(options).
For(&infrav1.IonosCloudMachine{}).
Watches(
&clusterv1.Machine{},
handler.EnqueueRequestsFromMapFunc(
util.MachineToInfrastructureMapFunc(infrav1.GroupVersion.WithKind(infrav1.IonosCloudMachineType)))).
// Reconcile the machines when their Cluster is unpaused or its infrastructure becomes ready.
// Reconciles skipped for those reasons don't requeue, so without this watch a machine can wait
// for an unrelated Machine event, e.g. when a reconcile read a stale paused Cluster from the cache.
Watches(
&clusterv1.Cluster{},
handler.EnqueueRequestsFromMapFunc(clusterToIonosCloudMachines),
builder.WithPredicates(predicates.ClusterPausedTransitionsOrInfrastructureProvisioned(mgr.GetScheme(), mgr.GetLogger())),
).
Complete(reconcile.AsReconciler(r.Client, r))
}

Expand Down
60 changes: 60 additions & 0 deletions internal/controller/ionoscloudmachine_controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
Copyright 2024 IONOS Cloud.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controller

import (
"errors"
"testing"

"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
conditions "sigs.k8s.io/cluster-api/util/conditions"

infrav1 "github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1"
)

func TestSetProvisioningCondition(t *testing.T) {
machineWith := func(status metav1.ConditionStatus, reason string, provisioned bool) *infrav1.IonosCloudMachine {
m := &infrav1.IonosCloudMachine{}
if provisioned {
m.Status.Initialization.Provisioned = new(true)
}
conditions.Set(m, metav1.Condition{Type: infrav1.MachineProvisionedCondition, Status: status, Reason: reason})
return m
}

// A stale "waiting" reason is replaced once provisioning starts.
m := machineWith(metav1.ConditionFalse, infrav1.WaitingForBootstrapDataReason, false)
setProvisioningCondition(m, nil)
c := conditions.Get(m, infrav1.MachineProvisionedCondition)
require.Equal(t, metav1.ConditionFalse, c.Status)
require.Equal(t, infrav1.MachineProvisioningReason, c.Reason)

// A failing step is surfaced with its error.
setProvisioningCondition(m, errors.New("error in step ReconcileServer: image lookup failed"))
c = conditions.Get(m, infrav1.MachineProvisionedCondition)
require.Equal(t, metav1.ConditionFalse, c.Status)
require.Equal(t, infrav1.MachineProvisioningFailedReason, c.Reason)
require.Equal(t, "error in step ReconcileServer: image lookup failed", c.Message)

// A provisioned machine keeps its True condition on a transient error.
m = machineWith(metav1.ConditionTrue, infrav1.MachineProvisionedReason, true)
setProvisioningCondition(m, errors.New("transient"))
c = conditions.Get(m, infrav1.MachineProvisionedCondition)
require.Equal(t, metav1.ConditionTrue, c.Status)
require.Equal(t, infrav1.MachineProvisionedReason, c.Reason)
}
21 changes: 17 additions & 4 deletions internal/service/cloud/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,20 @@ var errMissingMachineVersion = errors.New("machine is missing version field")
type imageMatchError struct {
imageIDs []string
selector *infrav1.ImageSelector
location string
// version is the name filter from useMachineVersion; empty if the filter is disabled.
version string
// labelMatches counts the images that matched the selector in location, before the name filter.
labelMatches int
}

func (e imageMatchError) Error() string {
return fmt.Sprintf("found %d images matching selector %q",
len(e.imageIDs), labels.SelectorFromSet(e.selector.MatchLabels))
msg := fmt.Sprintf("found %d images matching selector %q in location %q",
len(e.imageIDs), labels.SelectorFromSet(e.selector.MatchLabels), e.location)
if e.version != "" {
msg += fmt.Sprintf(" with name containing %q (%d before the name filter)", e.version, e.labelMatches)
}
return msg
}

func (s *Service) lookupImageID(ctx context.Context, ms *scope.Machine) (string, error) {
Expand All @@ -62,23 +71,27 @@ func (s *Service) lookupImageID(ctx context.Context, ms *scope.Machine) (string,
return "", err
}

matchErr := imageMatchError{selector: imageSpec.Selector, location: location, labelMatches: len(images)}

if ptr.Deref(imageSpec.Selector.UseMachineVersion, true) {
version := ms.Machine.Spec.Version
if version == "" {
return "", errMissingMachineVersion
}

images = filterImagesByName(images, version)
matchErr.version = version
}

if len(images) == 0 {
return "", imageMatchError{selector: imageSpec.Selector}
return "", matchErr
}

switch imageSpec.Selector.ResolutionPolicy {
case infrav1.ResolutionPolicyExact:
if len(images) > 1 {
return "", imageMatchError{imageIDs: getImageIDs(images), selector: imageSpec.Selector}
matchErr.imageIDs = getImageIDs(images)
return "", matchErr
}
case infrav1.ResolutionPolicyNewest:
slices.SortFunc(images, func(lhs, rhs *sdk.Image) int {
Expand Down
14 changes: 14 additions & 0 deletions internal/service/cloud/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ func (s *imageTestSuite) TestLookupImageNoMatch() {
typedErr := new(imageMatchError)
s.ErrorAs(err, typedErr)
s.Empty(typedErr.imageIDs)
s.EqualError(err, `found 0 images matching selector "test=image" in location "loc"`+
` with name containing "v1.26.12" (0 before the name filter)`)
}

func (s *imageTestSuite) TestLookupImageNoMatchingName() {
s.ionosClient.EXPECT().ListLabels(s.ctx).Return(
[]sdk.Label{makeTestLabel("image", "image-1", "test", "image")}, nil,
).Once()
s.ionosClient.EXPECT().GetDatacenterLocationByID(s.ctx, s.infraMachine.Spec.DatacenterID).Return("loc", nil).Once()
s.ionosClient.EXPECT().GetImage(s.ctx, "image-1").Return(makeTestImage("image-1", "img-v1.30.6", "loc"), nil).Once()

_, err := s.service.lookupImageID(s.ctx, s.machineScope)
s.EqualError(err, `found 0 images matching selector "test=image" in location "loc"`+
` with name containing "v1.26.12" (1 before the name filter)`)
}

func (s *imageTestSuite) TestLookupImageTooManyMatches() {
Expand Down
Loading
Loading