Skip to content

Commit

Permalink
✨ Image lookup (#179)
Browse files Browse the repository at this point in the history
**What is the purpose of this pull request/Why do we need it?**

Implement image lookup at reconcile time according to label and name
selectors. This allows to update the Kubernetes version only by
modifying the CAPI resources instead of updating the image ID.

**Issue #, if available:**

**Description of changes:**

**Special notes for your reviewer:**

I added the following label to my images: `clusterapi=ionoscloud`.

Tested by deploying the new template and updating `KubeadmControlPlane`
and `MachineDeployment` versions (1.28.7 -> 1.29.2).

```
kubectl patch kcp test-control-plane --type merge -p '{"spec":{"template":{"spec":{"version":"1.29.2"}}}}'
kubectl patch md test-worker --type merge -p '{"spec":{"version":"1.29.2"}}'
```

The PR is big as it is, so I'll look into an e2e test as a follow-up.

**Checklist:**
- [x] Documentation updated
- [x] Unit Tests added
- [ ] E2E Tests added
- [x] Includes
[emojis](https://github.com/kubernetes-sigs/kubebuilder-release-tools?tab=readme-ov-file#kubebuilder-project-versioning)

---------

Co-authored-by: Jonas Riedel <[email protected]>
  • Loading branch information
avorima and jriedel-ionos authored Jul 16, 2024
1 parent 72324c0 commit 571951b
Show file tree
Hide file tree
Showing 15 changed files with 1,139 additions and 19 deletions.
30 changes: 26 additions & 4 deletions api/v1alpha1/ionoscloudmachine_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,37 @@ type Volume struct {
AvailabilityZone AvailabilityZone `json:"availabilityZone,omitempty"`

// Image is the image to use for the VM.
//+required
//+kubebuilder:validation:XValidation:rule="self.id != '' || has(self.selector)",message="must provide either id or selector"
Image *ImageSpec `json:"image"`
}

// ImageSpec defines the image to use for the VM.
type ImageSpec struct {
// ID is the ID of the image to use for the VM.
//+kubebuilder:validation:MinLength=1
ID string `json:"id"`
// ID is the ID of the image to use for the VM. Has precedence over selector.
//
//+optional
ID string `json:"id,omitempty"`

// Selector is used to look up images by name and labels.
// Only images in the IonosCloudCluster's location are considered.
//
//+optional
Selector *ImageSelector `json:"selector,omitempty"`
}

// ImageSelector defines label selectors for looking up images.
type ImageSelector struct {
// MatchLabels is a map of key/value pairs.
//
//+kubebuilder:validation:MinProperties=1
MatchLabels map[string]string `json:"matchLabels"`

// UseMachineVersion indicates whether to use the parent Machine's version field to look up image names.
// Enabled by default.
//
//+kubebuilder:default=true
//+optional
UseMachineVersion *bool `json:"useMachineVersion,omitempty"`
}

// IonosCloudMachineStatus defines the observed state of IonosCloudMachine.
Expand Down
19 changes: 18 additions & 1 deletion api/v1alpha1/ionoscloudmachine_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,16 +311,33 @@ var _ = Describe("IonosCloudMachine Tests", func() {
m.Spec.Disk.Image = nil
Expect(k8sClient.Create(context.Background(), m)).ToNot(Succeed())
})
It("should fail none is set", func() {
It("should fail if no fields are set", func() {
m := defaultMachine()
m.Spec.Disk.Image.ID = ""
Expect(k8sClient.Create(context.Background(), m)).ToNot(Succeed())
})
It("should fail if no match labels are set", func() {
m := defaultMachine()
m.Spec.Disk.Image.Selector = &ImageSelector{}
Expect(k8sClient.Create(context.Background(), m)).ToNot(Succeed())
})
It("should not fail if ID is set", func() {
m := defaultMachine()
m.Spec.Disk.Image.ID = "1eef-48ec-a246-a51a33aa4f3a"
Expect(k8sClient.Create(context.Background(), m)).To(Succeed())
})
It("should not fail if selector is set", func() {
m := defaultMachine()
m.Spec.Disk.Image.ID = ""
m.Spec.Disk.Image.Selector = &ImageSelector{
MatchLabels: map[string]string{
"foo": "bar",
},
}
Expect(k8sClient.Create(context.Background(), m)).To(Succeed())
Expect(m.Spec.Disk.Image.Selector.UseMachineVersion).ToNot(BeNil())
Expect(*m.Spec.Disk.Image.Selector.UseMachineVersion).To(BeTrue())
})
})
})
Context("Additional Networks", func() {
Expand Down
34 changes: 33 additions & 1 deletion api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,32 @@ spec:
properties:
id:
description: ID is the ID of the image to use for the VM.
minLength: 1
Has precedence over selector.
type: string
required:
- id
selector:
description: |-
Selector is used to look up images by name and labels.
Only images in the IonosCloudCluster's location are considered.
properties:
matchLabels:
additionalProperties:
type: string
description: MatchLabels is a map of key/value pairs.
minProperties: 1
type: object
useMachineVersion:
default: true
description: |-
UseMachineVersion indicates whether to use the parent Machine's version field to look up image names.
Enabled by default.
type: boolean
required:
- matchLabels
type: object
type: object
x-kubernetes-validations:
- message: must provide either id or selector
rule: self.id != '' || has(self.selector)
name:
description: Name is the name of the volume
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,33 @@ spec:
properties:
id:
description: ID is the ID of the image to use for
the VM.
minLength: 1
the VM. Has precedence over selector.
type: string
required:
- id
selector:
description: |-
Selector is used to look up images by name and labels.
Only images in the IonosCloudCluster's location are considered.
properties:
matchLabels:
additionalProperties:
type: string
description: MatchLabels is a map of key/value
pairs.
minProperties: 1
type: object
useMachineVersion:
default: true
description: |-
UseMachineVersion indicates whether to use the parent Machine's version field to look up image names.
Enabled by default.
type: boolean
required:
- matchLabels
type: object
type: object
x-kubernetes-validations:
- message: must provide either id or selector
rule: self.id != '' || has(self.selector)
name:
description: Name is the name of the volume
type: string
Expand Down
19 changes: 19 additions & 0 deletions docs/custom-image.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,22 @@ Now, you can copy the ID of your image and set it as the `IONOSCLOUD_MACHINE_IMA

> [!IMPORTANT]
> Please ensure to update the KUBERNETES_VERSION in your environment file (envfile) if it changes.
### Enabling image lookup

The provider is able to look up images by label and name for `IonosCloudMachine` resources that make use of an image
selector.
By default, the Kubernetes version of the parent Machine is used, so it's safe to reuse label keys and values for images
that contain the version in their name.

Currently, it's only possible to label images using the REST API:

```sh
curl -H "Authorization: Bearer <JWT>" -H "Content-Type: application/json" -X POST \
https://api.ionos.com/cloudapi/v6/images/<image-id>/labels -d '{"properties":{"key":"<some key>","value":"<some value>"}}'
```

Now, you can set the key and value as `IONOSCLOUD_IMAGE_LABEL_KEY` and `IONOSCLOUD_IMAGE_LABEL_VALUE` environment variables.
Your custom image will then be used when using the [`auto-image`](/templates/cluster-template-auto-image.yaml) template.

Given the correct labels the Kubernetes version is the only value that needs to be updated for version upgrades.
14 changes: 10 additions & 4 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,11 @@ export EXP_CLUSTER_RESOURCE_SET="true"

We provide the following templates:

| Flavor | Template File | CRS File |
|----------------|----------------------------------------|-------------------------------|
| calico | templates/cluster-template-calico.yaml | templates/crs/cni/calico.yaml |
| default | templates/cluster-template.yaml | - |
| Flavor | Template File | CRS File |
| ---------------- | ---------------------------------------------- | ------------------------------- |
| default | templates/cluster-template.yaml | - |
| calico | templates/cluster-template-calico.yaml | templates/crs/cni/calico.yaml |
| auto-image | templates/cluster-template-auto-image.yaml | - |


#### Flavor with Calico CNI
Expand Down Expand Up @@ -197,6 +198,11 @@ $ clusterctl generate cluster dev-calico \
$ kubectl apply -f cluster.yaml
```

#### Flavor with auto image lookup

This template makes use of labels on custom built images.
Please refer to the [custom image docs](/docs/custom-image.md) for more information.

### Custom Templates

If you need anything specific that requires a more complex setup, we recommend to use custom templates:
Expand Down
4 changes: 4 additions & 0 deletions internal/ionoscloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,8 @@ type Client interface {
PatchNIC(ctx context.Context, datacenterID, serverID, nicID string, properties sdk.NicProperties) (string, error)
// GetDatacenterLocationByID returns the location of the data center identified by datacenterID.
GetDatacenterLocationByID(ctx context.Context, datacenterID string) (string, error)
// GetImage returns the image identified by imageID.
GetImage(ctx context.Context, imageID string) (*sdk.Image, error)
// ListLabels returns a list of all available resource labels.
ListLabels(ctx context.Context) ([]sdk.Label, error)
}
23 changes: 23 additions & 0 deletions internal/ionoscloud/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,26 @@ func (c *IonosCloudClient) GetDatacenterLocationByID(ctx context.Context, datace

return *datacenter.Properties.Location, nil
}

// GetImage returns the image identified by imageID.
func (c *IonosCloudClient) GetImage(ctx context.Context, imageID string) (*sdk.Image, error) {
image, _, err := c.API.ImagesApi.ImagesFindById(ctx, imageID).Execute()
if err != nil {
return nil, fmt.Errorf(apiCallErrWrapper, err)
}

return &image, nil
}

// ListLabels returns a list of all available resource labels.
func (c *IonosCloudClient) ListLabels(ctx context.Context) ([]sdk.Label, error) {
labels, _, err := c.API.LabelsApi.
LabelsGet(ctx).
Depth(1). // always use depth 1 because we need the list item properties
Execute()
if err != nil {
return nil, fmt.Errorf(apiCallErrWrapper, err)
}

return *labels.Items, nil
}
26 changes: 26 additions & 0 deletions internal/ionoscloud/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,29 @@ func TestWithDepth(t *testing.T) {
})
}
}

func (s *IonosCloudClientTestSuite) TestGetImage() {
httpmock.RegisterResponder(
http.MethodGet,
catchAllMockURL,
httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{}),
)
image, err := s.client.GetImage(s.ctx, exampleID)
s.NoError(err)
s.NotNil(image)
}

func (s *IonosCloudClientTestSuite) TestListLabels() {
httpmock.RegisterResponder(
http.MethodGet,
catchAllMockURL,
httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
"items": []map[string]any{
{},
},
}),
)
labels, err := s.client.ListLabels(s.ctx)
s.NoError(err)
s.NotEmpty(labels)
}
Loading

0 comments on commit 571951b

Please sign in to comment.